Add plug-in-only filter to web app + deploy script
- Checkbox 'plug-in only (≤ 0.8 kW, no electrician)' filters the leaderboard client-side and re-picks the top recommendation from visible rows. Filter state is persisted in localStorage. - scripts/deploy.sh: idempotent rsync + systemd setup against the 'thuisbatterij' SSH alias. Run after provisioning the h4a workload.
This commit is contained in:
parent
60e0706736
commit
1d7574e0b2
5 changed files with 158 additions and 6 deletions
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PORT=8080
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --no-cache-dir uv \
|
||||
&& uv pip install --system --no-cache .
|
||||
|
||||
COPY data ./data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["sh", "-c", "gunicorn pluginbattery.web:app --bind 0.0.0.0:${PORT:-8080} --workers 1 --timeout 120"]
|
||||
61
scripts/deploy.sh
Executable file
61
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy (or redeploy) the web app to the h4a workload.
|
||||
#
|
||||
# Prereqs:
|
||||
# - Workload provisioned via h4a (`thuisbatterij`).
|
||||
# - SSH alias `thuisbatterij` in ~/.ssh/config pointing at it.
|
||||
# If h4a rotates the VM, update the HostName in your SSH config and re-run.
|
||||
#
|
||||
# Usage: scripts/deploy.sh
|
||||
set -euo pipefail
|
||||
|
||||
ALIAS="thuisbatterij"
|
||||
APP_DIR="/opt/thuisbatterij"
|
||||
|
||||
echo "→ checking SSH"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 ${ALIAS} 'true'
|
||||
|
||||
echo "→ rsyncing source to ${ALIAS}:${APP_DIR}"
|
||||
ssh ${ALIAS} "mkdir -p ${APP_DIR}"
|
||||
rsync -az --delete \
|
||||
--exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' \
|
||||
--exclude='.pytest_cache' --exclude='.ruff_cache' --exclude='.mypy_cache' \
|
||||
--exclude='data/processed' --exclude='.DS_Store' --exclude='.env' \
|
||||
--exclude='.git' \
|
||||
./ ${ALIAS}:${APP_DIR}/
|
||||
|
||||
echo "→ installing python deps"
|
||||
ssh ${ALIAS} "cd ${APP_DIR} && \
|
||||
apt-get install -y python3-venv python3.12-venv >/dev/null 2>&1 && \
|
||||
([ -d .venv ] || python3 -m venv .venv) && \
|
||||
.venv/bin/pip install -q --upgrade pip && \
|
||||
.venv/bin/pip install -q -e ."
|
||||
|
||||
echo "→ installing/updating systemd unit"
|
||||
ssh ${ALIAS} "cat >/etc/systemd/system/thuisbatterij.service <<UNIT
|
||||
[Unit]
|
||||
Description=Thuisbatterij payback web app
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=${APP_DIR}/.venv/bin/gunicorn pluginbattery.web:app --bind 0.0.0.0:8080 --workers 1 --timeout 180
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
systemctl stop h4a-app.service 2>/dev/null || true
|
||||
systemctl disable h4a-app.service 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now thuisbatterij.service
|
||||
systemctl restart thuisbatterij.service"
|
||||
|
||||
sleep 3
|
||||
echo "→ smoke test"
|
||||
ssh ${ALIAS} "curl -sf http://127.0.0.1:8080/healthz && echo"
|
||||
|
||||
echo "→ done. If first deploy or DNS just changed, expose + bounce caddy:"
|
||||
echo " ssh ${ALIAS} 'systemctl restart caddy'"
|
||||
|
|
@ -9,6 +9,13 @@ const tbody = document.getElementById("leaderboard-body");
|
|||
const countEl = document.getElementById("count");
|
||||
const elapsed = document.getElementById("elapsed");
|
||||
const bestEl = document.getElementById("best-content");
|
||||
const pluginOnly = document.getElementById("plugin-only");
|
||||
|
||||
const PLUGIN_POWER_LIMIT_KW = 0.8;
|
||||
const FILTER_KEY = "pluginbattery_plugin_only";
|
||||
|
||||
// Latest fetched dataset; the filter toggle re-paints from this.
|
||||
let lastBatteries = null;
|
||||
|
||||
function fmtNum(x, decimals = 2) {
|
||||
return x === null || x === undefined ? "—" : Number(x).toFixed(decimals);
|
||||
|
|
@ -96,6 +103,19 @@ function renderTable(rows) {
|
|||
});
|
||||
}
|
||||
|
||||
function applyFilter(batteries) {
|
||||
if (!pluginOnly.checked) return batteries;
|
||||
return batteries.filter((b) => b.power_kw <= PLUGIN_POWER_LIMIT_KW + 1e-9);
|
||||
}
|
||||
|
||||
function repaint() {
|
||||
if (!lastBatteries) return;
|
||||
const visible = applyFilter(lastBatteries);
|
||||
renderBest(visible[0] || null);
|
||||
renderTable(visible);
|
||||
countEl.textContent = visible.length;
|
||||
}
|
||||
|
||||
async function recalc() {
|
||||
const scenario = readScenario();
|
||||
button.disabled = true;
|
||||
|
|
@ -112,10 +132,9 @@ async function recalc() {
|
|||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
renderBest(data.best);
|
||||
renderTable(data.batteries);
|
||||
countEl.textContent = data.batteries.length;
|
||||
lastBatteries = data.batteries;
|
||||
elapsed.textContent = data.elapsed_seconds;
|
||||
repaint();
|
||||
statusEl.textContent = `Updated in ${((performance.now() - t0) / 1000).toFixed(1)}s.`;
|
||||
} catch (e) {
|
||||
statusEl.textContent = `Error: ${e.message}`;
|
||||
|
|
@ -124,4 +143,36 @@ async function recalc() {
|
|||
}
|
||||
}
|
||||
|
||||
// Capture the SSR-rendered table into lastBatteries so the filter works
|
||||
// before the user clicks Recalculate.
|
||||
function captureInitialFromDOM() {
|
||||
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||
lastBatteries = rows.map((tr) => {
|
||||
const cells = tr.children;
|
||||
const link = cells[1].querySelector("a");
|
||||
return {
|
||||
title: (link ? link.textContent : cells[1].textContent).trim(),
|
||||
url: link ? link.href : "",
|
||||
capacity_kwh: parseFloat(cells[2].textContent),
|
||||
power_kw: parseFloat(cells[3].textContent),
|
||||
price_eur: parseFloat(cells[4].textContent.replace("€", "")),
|
||||
lp_year1: parseFloat(cells[5].textContent.replace("€", "")),
|
||||
lp_payback: cells[6].textContent.trim() === "—" ? null : parseFloat(cells[6].textContent),
|
||||
store_year1: parseFloat(cells[7].textContent.replace("€", "")),
|
||||
store_payback: cells[8].textContent.trim() === "—" ? null : parseFloat(cells[8].textContent),
|
||||
overstatement: cells[9].textContent.trim() === "—" ? null : parseFloat(cells[9].textContent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Restore filter state from localStorage and wire up listeners.
|
||||
pluginOnly.checked = localStorage.getItem(FILTER_KEY) === "true";
|
||||
pluginOnly.addEventListener("change", () => {
|
||||
localStorage.setItem(FILTER_KEY, pluginOnly.checked);
|
||||
repaint();
|
||||
});
|
||||
|
||||
captureInitialFromDOM();
|
||||
if (pluginOnly.checked) repaint(); // honour saved filter on load
|
||||
|
||||
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
|
||||
|
|
|
|||
|
|
@ -125,6 +125,21 @@ section#results {
|
|||
.card h2 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
|
||||
.card h2 small { font-weight: 400; text-transform: none; opacity: 0.8; margin-left: 0.5rem; }
|
||||
|
||||
.filter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.filter-toggle input { margin: 0; }
|
||||
|
||||
#best-card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Honest battery payback — vs thuisbatterijgids.nl</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=4">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=5">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -78,7 +78,11 @@
|
|||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Full leaderboard <small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small></h2>
|
||||
<h2>
|
||||
Full leaderboard
|
||||
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s)</small>
|
||||
<label class="filter-toggle"><input type="checkbox" id="plugin-only"> plug-in only (≤ 0.8 kW, no electrician)</label>
|
||||
</h2>
|
||||
<table id="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -124,6 +128,6 @@
|
|||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue