diff --git a/.changeset/dashboard-light-dark.md b/.changeset/dashboard-light-dark.md new file mode 100644 index 00000000..3428cc7b --- /dev/null +++ b/.changeset/dashboard-light-dark.md @@ -0,0 +1,13 @@ +--- +"forty-two-watts": patch +--- + +Dashboard light-mode + heat-pump card fixes. + +The live power/energy **charts now render correctly in light mode**. The canvas chrome (axis labels, gridlines, zero/now/hover lines, both tooltips' background+border+text, and the neutral "Load" line + legend swatch) was hard-coded for the dark theme, so it went invisible or wrong on a light background. The charts now resolve the CSS theme tokens (`--fg`, `--fg-dim`, `--fg-muted`, `--line`, `--ink-raised`, `--accent-e`) into concrete canvas colors per draw — cached and re-read when `data-theme` changes. Saturated data-series hues are unchanged. + +The **heat-pump card now re-discovers newly-added drivers** without a manual reload. `heating.js` cached discovery once and never re-checked — and an empty result is truthy, so a site that discovered before its pump reported `hp_power_w` stayed blank. It now re-scans on first load and every 5 minutes, while steady-state polling still only touches already-known heat-pump drivers. + +The heat-pump **"all signals" detail view now shows a Register column** — each signal's source Modbus register id (read from the metric snapshot the driver reports). Signals with no Modbus mapping show "—". + +The **heat-pump card itself now themes in light mode**. It renders into a bare `.card`, whose base style uses the `--surface` token — which the light theme doesn't flip — so the card stayed dark on a light page while every other dashboard card themed correctly. It now uses the same `--ink-raised` palette as the rest. diff --git a/web/app.js b/web/app.js index b16cbaea..0f24e04e 100644 --- a/web/app.js +++ b/web/app.js @@ -504,6 +504,8 @@ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } + var C = chartColors(); // theme-aware chrome colors, re-read each draw + var pad = { top: 20, right: 10, bottom: 25, left: 55 }; var plotW = w - pad.left - pad.right; var plotH = h - pad.top - pad.bottom; @@ -529,13 +531,13 @@ { data: toKwh(chartHistory.e_pv), color: "#10b981", width: 2, dash: [], name: "PV", fill: true }, { data: toKwh(chartHistory.e_charged), color: "#3b82f6", width: 2, dash: [], name: "Charged", fill: false }, { data: toKwh(chartHistory.e_discharged), color: "#f59e0b", width: 2, dash: [], name: "Discharged", fill: false }, - { data: toKwh(chartHistory.e_load), color: "#e2e8f0", width: 2, dash: [], name: "Load", fill: false }, + { data: toKwh(chartHistory.e_load), color: C.load, width: 2, dash: [], name: "Load", fill: false }, ]; } else { series = [ { data: chartHistory.grid, color: "#ef4444", width: 2, dash: [], name: "Grid", fill: true, toggle: "grid" }, { data: chartHistory.pv, color: "#22c55e", width: 2, dash: [], name: "PV", fill: true, toggle: "pv" }, - { data: chartHistory.load, color: "#e2e8f0", width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, + { data: chartHistory.load, color: C.load, width: 1.5, dash: [], name: "Load", fill: false, toggle: "load" }, ]; // Append one actual/target pair per discovered battery driver. // Stable order so chart colors don't jump as the driver set grows. @@ -567,7 +569,7 @@ if (visibleVals.length === 0) { // Empty state — draw axes + "waiting for data" hint ctx.clearRect(0, 0, w, h); - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.font = "12px monospace"; ctx.textAlign = "center"; ctx.fillText("waiting for data...", w / 2, h / 2); @@ -615,7 +617,7 @@ // Grid lines (drawn inside clip so they only appear in the plot area). // Walk yMin..yMax in yStep increments so every line lands on a round // number — that's what lets the y-axis labels stay readable. - ctx.strokeStyle = "#2a2a2a"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 0.5; ctx.font = "11px monospace"; var steps = Math.round(yRange / yStep); @@ -630,7 +632,7 @@ // Zero line if (yMin < 0 && yMax > 0) { var zeroY = pad.top + plotH * (1 - (0 - yMin) / yRange); - ctx.strokeStyle = "#444"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); @@ -795,7 +797,7 @@ // Subtle vertical "now" line — anchor point so the eye knows where present is var nowX = pad.left + plotW; - ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(nowX, pad.top); @@ -803,7 +805,7 @@ ctx.stroke(); // Y-axis labels (outside clip so they're fully visible) - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = "11px monospace"; for (var i2 = 0; i2 <= steps; i2++) { var yVal = yMin + (yRange * i2 / steps); @@ -812,7 +814,7 @@ } // Time labels - ctx.fillStyle = "#666"; + ctx.fillStyle = C.muted; ctx.fillText(chartRange + " ago", pad.left, h - 5); ctx.textAlign = "right"; ctx.fillText("now", w - pad.right, h - 5); @@ -838,7 +840,7 @@ ctx.arc(w - pad.right - 78, pad.top + 4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.font = "10px monospace"; - ctx.fillStyle = fresh ? "#aaa" : "#f59e0b"; + ctx.fillStyle = fresh ? C.dim : "#f59e0b"; ctx.fillText(ageStr, w - pad.right - 70, pad.top + 8); } @@ -870,8 +872,46 @@ return "rgba(" + r + "," + g + "," + b + "," + alpha + ")"; } + // Resolve a CSS custom property to a concrete color string for , + // which can't read var(). Re-read per draw so the charts follow the active + // theme (html[data-theme]) without needing an explicit redraw on toggle — + // a hidden probe element inherits :root, and getComputedStyle resolves the + // var() + oklch token to a value canvas can paint. + var _colorProbe = null; + function cssColor(name, fallback) { + if (!_colorProbe) { + _colorProbe = document.createElement("span"); + _colorProbe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none"; + document.body.appendChild(_colorProbe); + } + _colorProbe.style.color = "var(" + name + ", " + (fallback || "#888") + ")"; + return getComputedStyle(_colorProbe).color || fallback || "#888"; + } + // Theme-aware chart chrome colors. Series hues stay fixed (they read on + // both themes); only text / gridlines / tooltip surface / the neutral + // "load" line flip — those are what go invisible on a light background. + // Cached so the animation loop doesn't getComputedStyle every frame — + // only recomputed when the active theme actually changes. + var _chartColors = null, _chartColorsTheme = null; + function chartColors() { + var theme = document.documentElement.getAttribute("data-theme") || ""; + if (_chartColors && _chartColorsTheme === theme) return _chartColors; + _chartColorsTheme = theme; + _chartColors = { + text: cssColor("--fg", "#e6e6e6"), + dim: cssColor("--fg-dim", "#aaaaaa"), + muted: cssColor("--fg-muted", "#888888"), + grid: cssColor("--line", "#2a2a2a"), + surface: cssColor("--ink-raised", "#14141f"), + accent: cssColor("--accent-e", "#fbbf24"), + load: cssColor("--fg", "#e2e8f0"), + }; + return _chartColors; + } + function drawHoverOverlay(ctx) { if (!chartLayout) return; + var C = chartColors(); var l = chartLayout; var i = hoverIndex; // Map by timestamp (matches the time-anchored line drawing) @@ -880,7 +920,7 @@ var x = l.pad.left + l.plotW * (ts - l.windowStart) / l.totalMs; // Vertical line - ctx.strokeStyle = "rgba(255,255,255,0.3)"; + ctx.strokeStyle = C.muted; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); @@ -906,12 +946,12 @@ { name: "PV", data: chartHistory.e_pv, color: "#10b981" }, { name: "Charged", data: chartHistory.e_charged, color: "#3b82f6" }, { name: "Discharged", data: chartHistory.e_discharged, color: "#f59e0b" }, - { name: "Load", data: chartHistory.e_load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.e_load, color: C.load }, ] : (function () { var rows = [ { name: "Grid", data: chartHistory.grid, color: "#ef4444" }, { name: "PV", data: chartHistory.pv, color: "#22c55e" }, - { name: "Load", data: chartHistory.load, color: "#e2e8f0" }, + { name: "Load", data: chartHistory.load, color: C.load }, ]; // Battery rows render their target inline as "actual W (→ target W)" // so it's visually obvious the two numbers are the same metric — one @@ -934,14 +974,14 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.95)"; - ctx.strokeStyle = "#444"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.grid; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); ctx.font = "10px monospace"; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.fillText(timeStr, boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { @@ -949,21 +989,21 @@ var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = lab.dim ? "#888" : "#ddd"; + ctx.fillStyle = lab.dim ? C.muted : C.text; ctx.fillText(lab.name, boxX + 18, y); ctx.textAlign = "right"; if (chartView === "energy") { - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { var actual = formatW(lab.data[i]); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.fillText(actual, boxX + boxW - 6, y); // Inline target as dim "(→ -674 W)" so user sees commanded vs actual // in one glance. Skip when target is 0 to reduce visual noise. if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { var actualW = ctx.measureText(actual).width; - ctx.fillStyle = "#888"; + ctx.fillStyle = C.dim; ctx.font = "9px monospace"; ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y); ctx.font = "10px monospace"; @@ -975,6 +1015,7 @@ function drawForecastHoverOverlay(ctx) { if (!chartLayout || !hoverForecast) return; + var C = chartColors(); var l = chartLayout; var a = hoverForecast.action; var ts = hoverForecast.ts; @@ -1007,8 +1048,8 @@ if (boxX + boxW > l.w - 5) boxX = x - boxW - 10; var boxY = l.pad.top + 5; - ctx.fillStyle = "rgba(20,20,35,0.96)"; - ctx.strokeStyle = "rgba(251,191,36,0.6)"; + ctx.fillStyle = C.surface; + ctx.strokeStyle = C.accent; ctx.lineWidth = 1; ctx.fillRect(boxX, boxY, boxW, boxH); ctx.strokeRect(boxX, boxY, boxW, boxH); @@ -1016,16 +1057,16 @@ ctx.font = "10px monospace"; var d = new Date(ts); var hh = d.getHours().toString().padStart(2, "0") + ":" + d.getMinutes().toString().padStart(2, "0"); - ctx.fillStyle = "#fbbf24"; + ctx.fillStyle = C.accent; ctx.fillText(hh + " predicted", boxX + 6, boxY + lineHeight - 2); labels.forEach(function (lab, idx) { var y = boxY + (idx + 2) * lineHeight - 4; ctx.fillStyle = lab.color; ctx.fillRect(boxX + 6, y - 8, 8, 8); - ctx.fillStyle = "#ddd"; + ctx.fillStyle = C.text; ctx.fillText(lab.name, boxX + 18, y); - ctx.fillStyle = "#fff"; + ctx.fillStyle = C.text; ctx.textAlign = "right"; var val = lab.literal ? lab.val : formatW(lab.val); ctx.fillText(val, boxX + boxW - 6, y); @@ -1034,7 +1075,7 @@ if (a.reason) { var ry = boxY + (labels.length + 2) * lineHeight + 2; - ctx.fillStyle = "#86efac"; + ctx.fillStyle = C.dim; ctx.font = "italic 10px monospace"; // Truncate if too long for box var reason = a.reason.length > 28 ? a.reason.substring(0, 27) + "…" : a.reason; @@ -2078,9 +2119,9 @@ if (!legend) return; var items = chartView === "energy" ? [ ["#ef4444", "Import"], ["#22c55e", "Export"], ["#10b981", "PV"], - ["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["#e2e8f0", "Load"], + ["#3b82f6", "Charged"], ["#f59e0b", "Discharged"], ["var(--fg)", "Load"], ] : [ - ["#ef4444", "Grid"], ["#22c55e", "PV"], ["#e2e8f0", "Load"], + ["#ef4444", "Grid"], ["#22c55e", "PV"], ["var(--fg)", "Load"], ["#f59e0b", "Ferroamp"], ["#8b5cf6", "Sungrow"], ]; legend.innerHTML = items.map(function(it) { diff --git a/web/heating-request-budget.test.mjs b/web/heating-request-budget.test.mjs new file mode 100644 index 00000000..e7425ec3 --- /dev/null +++ b/web/heating-request-budget.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./heating.js', import.meta.url), 'utf8'); + +test('heat-pump history is cached instead of range-scanned every live refresh', () => { + assert.match(source, /HISTORY_REFRESH_MS\s*=\s*300000/); + assert.match(source, /historyCache\[name\]\s*=\s*\{\s*at:/); + assert.match(source, /fetchPumpHistory\(n\)/); +}); + +test('all-signals view does not fetch one history series per NIBE point', () => { + assert.match(source, /metrics\.filter\(function \(m\) \{ return !!infoForKey\(m\.name\); \}\)\.forEach/); +}); + +test('overlapping dashboard refreshes are suppressed', () => { + assert.match(source, /if \(refreshInFlight\) return;/); + assert.match(source, /refreshInFlight = false;/); +}); diff --git a/web/heating.js b/web/heating.js index 91c49535..682676ed 100644 --- a/web/heating.js +++ b/web/heating.js @@ -15,6 +15,11 @@ var REFRESH_MS = 30000; var timer = null; var heatPumpDrivers = null; // cached after discovery: array of driver names + var lastDiscoverMs = 0; // Date.now() of the last discovery scan + var DISCOVER_EVERY_MS = 300000; // re-scan for newly-added heat pumps (5 min) + var HISTORY_REFRESH_MS = 300000; // long TS queries refresh at most every 5 min + var historyCache = Object.create(null); + var refreshInFlight = false; // Route reads over the owner/P2P transport when present (remote home // route), else plain fetch (LAN / tests). Mirrors twins.js. @@ -23,41 +28,113 @@ return fetch(path, opts); } - // hp_* metric → display label + formatter. Order here is render order. - var METRICS = [ - { key: 'hp_power_w', label: 'Compressor', fmt: fmtPower }, - { key: 'hp_hw_top_temp_c', label: 'Hot water', fmt: fmtTemp }, - { key: 'hp_indoor_temp_c', label: 'Indoor', fmt: fmtTemp }, - { key: 'hp_outdoor_temp_c', label: 'Outdoor', fmt: fmtTemp }, - ]; - + // ── Card formatters ────────────────────────────────────────────── function fmtPower(v) { if (v == null) return '—'; if (Math.abs(v) >= 1000) return (v / 1000).toFixed(2) + ' kW'; return Math.round(v) + ' W'; } - function fmtTemp(v) { - if (v == null) return '—'; - return v.toFixed(1) + ' °C'; - } + function fmtTemp(v) { return v == null ? '—' : v.toFixed(1) + ' °C'; } + function fmtKW(v) { return v == null ? '—' : v.toFixed(2) + ' kW'; } + function fmtHz(v) { return v == null ? '—' : Math.round(v) + ' Hz'; } + function fmtAmp(v) { return v == null ? '—' : v.toFixed(1) + ' A'; } + function fmtPct(v) { return v == null ? '—' : Math.round(v) + ' %'; } + function fmtFlow(v) { return v == null ? '—' : Math.round(v) + ' m³/h'; } + function fmtDM(v) { return v == null ? '—' : Math.round(v) + ' DM'; } + function fmtKwh(v) { return v == null ? '—' : Math.round(v).toLocaleString('en-US') + ' kWh'; } + function fmtRaw(v) { return v == null ? '—' : String(Math.round(v * 100) / 100); } + function fmtOffset(v) { return v == null ? '—' : (v > 0 ? '+' : '') + Math.round(v); } + function fmtOnOff(v) { return v == null ? '—' : (Math.round(v) ? 'On' : 'Off'); } + // Compressor priority code → word (0/10 idle, 20 hot water, 30 heating, …). + var PRIO = { 0: 'Off', 10: 'Off', 20: 'Hot water', 30: 'Heating', 40: 'Pool', 60: 'Cooling' }; + function fmtPrio(v) { if (v == null) return '—'; var n = Math.round(v); return PRIO[n] || ('Mode ' + n); } + function fmtVent(v) { if (v == null) return '—'; var n = Math.round(v); return n === 0 ? 'Normal' : ('Mode ' + n); } + + // ── Card layout: grouped tiles. Each item = { key (hp_* metric), label, + // optional sensor designation (BT21 …), formatter, info (hover tooltip on + // the "?" help icon) }. Render order = array order. The detail pop-up still + // lists ALL ~960 signals; this is the curated at-a-glance set. + var GROUPS = [ + { title: 'Power & electrical', items: [ + { key: 'hp_energy_log_current_power_consumption', label: 'Total power now', fmt: fmtKW, info: "The whole heat pump's instantaneous electrical draw right now — compressor + fan + circulation pumps + electronics." }, + { key: 'hp_power_w', label: 'Compressor', fmt: fmtPower, info: 'Power to the compressor only. 0 W when the compressor is idle — the pump still draws power (see Total power now).' }, + { key: 'hp_power_internal_additional_heat', label: 'Internal add. heat', fmt: fmtKW, info: 'Power to the internal immersion heater (supplementary heat). 0 when only the compressor runs.' }, + { key: 'hp_compressor_frequency_current', label: 'Compr. freq.', fmt: fmtHz, info: 'Compressor speed/frequency right now. 0 Hz = compressor idle.' }, + { key: 'hp_current_be1', label: 'Current', sensor: 'BE1', fmt: fmtAmp, info: 'Measured current on phase 1 (current transformer BE1). Used by the load monitor / fuse protection.' }, + { key: 'hp_current_be2', label: 'Current', sensor: 'BE2', fmt: fmtAmp, info: 'Measured current on phase 2 (BE2).' }, + { key: 'hp_current_be3', label: 'Current', sensor: 'BE3', fmt: fmtAmp, info: 'Measured current on phase 3 (BE3).' }, + { key: 'hp_power_limitation_activation', label: 'Power limit', fmt: fmtOnOff, info: 'Whether the built-in power limitation (load monitor) is actively throttling to stay under the main fuse. Off = not limiting. Throttling only reduces output — it never damages the pump.' }, + { key: 'hp_fuse', label: 'Fuse', sensor: null, fmt: fmtAmp, info: 'Main fuse size the pump is configured for. It limits compressor + immersion heater to stay under this.' }, + { key: 'hp_max_internal_additional_heat', label: 'Max add. heat', fmt: fmtKW, info: 'Maximum permitted internal immersion-heater power. Lower this to cap the supplementary heat draw.' }, + ] }, + { title: 'Temperatures', items: [ + { key: 'hp_hw_top_temp_c', label: 'Hot water top', sensor: 'BT7', fmt: fmtTemp, info: 'Temperature at the top of the hot-water tank — what you get first from the tap.' }, + { key: 'hp_hot_water_charging_bt6', label: 'HW charging', sensor: 'BT6', fmt: fmtTemp, info: 'Controlling sensor for hot-water charging — decides when the tank is fully charged.' }, + { key: 'hp_hot_water_start_bt5', label: 'HW start', sensor: 'BT5', fmt: fmtTemp, info: 'Hot-water start sensor — triggers a new charge when it drops below the start value.' }, + { key: 'hp_supply_line_bt2', label: 'Supply', sensor: 'BT2', fmt: fmtTemp, info: 'Temperature of the water going OUT to the heating system (supply line).' }, + { key: 'hp_return_line_bt3', label: 'Return', sensor: 'BT3', fmt: fmtTemp, info: 'Temperature of the water coming BACK from the heating system (return line).' }, + { key: 'hp_calculated_supply_climate_system_1', label: 'Calc. supply', fmt: fmtTemp, info: 'Calculated (target) supply temperature the control derives from the heating curve.' }, + { key: 'hp_fr_nluft_bt20', label: 'Extract air', sensor: 'BT20', fmt: fmtTemp, info: "Ventilation air drawn from the rooms — the heat pump's heat source (into the evaporator)." }, + { key: 'hp_avluft_bt21', label: 'Exhaust air', sensor: 'BT21', fmt: fmtTemp, info: 'Air after heat recovery, on its way out of the house. Extract − exhaust = recovered heat.' }, + { key: 'hp_outdoor_temp_c', label: 'Outdoor', sensor: 'BT1', fmt: fmtTemp, info: 'Outdoor temperature (BT1) — drives the heating curve.' }, + ] }, + { title: 'Ventilation', items: [ + { key: 'hp_ventilation_mode', label: 'Vent. mode', fmt: fmtVent, info: 'Active ventilation mode. 0 = normal.' }, + { key: 'hp_exhaust_air_fan_speed_gq2', label: 'Fan speed', sensor: 'GQ2', fmt: fmtPct, info: 'Exhaust-air fan speed right now. Normal = 54 %; lower (e.g. 30 %) = reduced ventilation (speed 2).' }, + { key: 'hp_real_air_flow', label: 'Air flow', fmt: fmtFlow, info: 'Measured air flow through the unit.' }, + ] }, + { title: 'Operation', items: [ + { key: 'hp_priority', label: 'Priority', fmt: fmtPrio, info: 'What the compressor is prioritising right now: Off / Hot water / Heating / Pool / Cooling.' }, + { key: 'hp_degree_minutes', label: 'Degree minutes', fmt: fmtDM, info: 'Heating deficit integrated over time. When it reaches the start threshold the compressor starts. 0 = no deficit (heating off in summer).' }, + { key: 'hp_heating_medium_pump_speed_gp1', label: 'Circ. pump', sensor: 'GP1', fmt: fmtPct, info: 'Heating circulation pump (GP1) speed.' }, + { key: 'hp_heating_curve_climate_system_1', label: 'Heating curve', fmt: fmtRaw, info: 'Configured heating curve (slope) for climate system 1. Higher = warmer supply when it is cold outside.' }, + { key: 'hp_heating_offset_climate_system_1', label: 'Curve offset', fmt: fmtOffset, info: 'Parallel offset of the heating curve — warmer (+) or cooler (−) overall.' }, + ] }, + { title: 'Energy (lifetime)', items: [ + { key: 'hp_energy_consumed_kwh', label: 'Total consumed', fmt: fmtKwh, info: 'Total electricity supplied to the heat pump since installation (lifetime counter).' }, + { key: 'hp_energy_produced_kwh', label: 'Total produced', fmt: fmtKwh, info: 'Total heat energy delivered since installation. Produced ÷ consumed ≈ heat factor (SCOP).' }, + { key: 'hp_heating_compressor_only', label: 'Heating (compr.)', fmt: fmtKwh, info: 'Heat delivered to heating, from the compressor only (excl. immersion heater).' }, + { key: 'hp_hot_water_compressor_only', label: 'Hot water (compr.)', fmt: fmtKwh, info: 'Heat delivered to hot water, from the compressor only (excl. immersion heater).' }, + ] }, + ]; function injectStyles() { if (document.getElementById('ftw-heating-styles')) return; var css = [ '#heating-grid{display:flex;flex-direction:column;gap:18px}', - '.ftw-hp{display:flex;flex-direction:column;gap:12px}', + '.ftw-hp{display:flex;flex-direction:column;gap:16px}', '.ftw-hp-clickable{cursor:pointer;border-radius:8px;margin:-6px;padding:6px;transition:background 0.12s}', '.ftw-hp-clickable:hover,.ftw-hp-clickable:focus{background:var(--bg-hover,rgba(127,127,127,0.06));outline:none}', '.ftw-hp-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}', '.ftw-hp-more{font-family:var(--mono);font-size:0.66rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--accent-e)}', '.ftw-hp-name{font-family:var(--mono);font-size:0.72rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--fg-muted)}', - '.ftw-hp-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px 18px}', + '.ftw-hp-group{display:flex;flex-direction:column;gap:9px;background:var(--ink-sunken);border:1px solid var(--line);border-radius:10px;padding:12px 14px}', + '.ftw-hp-group-title{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.16em;text-transform:uppercase;color:var(--accent-e)}', + '.ftw-hp-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px 18px}', '.ftw-hp-tile{display:flex;flex-direction:column;gap:3px}', - '.ftw-hp-tile-label{font-family:var(--mono);font-size:0.68rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--fg-muted)}', - '.ftw-hp-tile-val{font-family:var(--mono);font-size:1.05rem;font-variant-numeric:tabular-nums;color:var(--fg)}', + '.ftw-hp-tile-label{font-family:var(--mono);font-size:0.64rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);display:flex;align-items:baseline;gap:3px;flex-wrap:wrap}', + '.ftw-hp-sensor{color:var(--fg-muted);opacity:0.6}', + '.ftw-hp-i{display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;border:1px solid var(--line);border-radius:50%;color:var(--fg-dim);font-family:var(--sans);font-size:9px;font-weight:600;line-height:1;cursor:help;transition:color 0.18s,border-color 0.18s}', + '.ftw-hp-i:hover{color:var(--accent-e);border-color:var(--accent-e)}', + '.ftw-hp-tile-val{font-family:var(--mono);font-size:1.02rem;font-variant-numeric:tabular-nums;color:var(--fg)}', '.ftw-hp-spark{display:flex;flex-direction:column;gap:4px}', '.ftw-hp-spark-label{font-family:var(--mono);font-size:0.66rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--fg-muted)}', '.ftw-hp-spark svg{width:100%;height:48px;display:block}', + '.ftw-hp-chartrow{display:flex;gap:6px;align-items:stretch}', + '.ftw-hp-tchart{flex:1;min-width:0;height:150px;display:block}', + '.ftw-hp-pchart{flex:1;min-width:0;height:90px;display:block}', + '.ftw-hp-yax{display:flex;flex-direction:column;justify-content:space-between;text-align:right;min-width:26px;padding:6px 0;font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:0.6rem;color:var(--fg-muted)}', + '.ftw-hp-xax{display:flex;justify-content:space-between;margin:3px 0 0 32px;font-family:var(--mono);font-size:0.6rem;color:var(--fg-muted)}', + '.ftw-hp-legend{display:flex;gap:14px;flex-wrap:wrap}', + '.ftw-hp-chartsub{font-family:var(--sans);font-size:0.72rem;color:var(--fg-muted);margin:-2px 0 6px}', + '.ftw-hp-asof{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.04em;color:var(--fg-muted);margin:-4px 0 10px}', + '.ftw-hp-leg{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted);display:inline-flex;align-items:center;gap:4px}', + '.ftw-hp-leg-dot{width:8px;height:8px;border-radius:2px;display:inline-block}', + '.ftw-hp-erow{display:grid;grid-template-columns:1fr auto auto;gap:7px 18px;align-items:baseline}', + '.ftw-hp-ehead{font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--fg-muted);font-family:var(--mono)}', + '.ftw-hp-elabel{font-family:var(--mono);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted)}', + '.ftw-hp-eval{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:0.92rem;color:var(--fg);text-align:right;min-width:72px}', + '.ftw-hp-acc{color:var(--fg-muted);opacity:0.7;font-size:0.74rem}', '.ftw-hp-empty{font-family:var(--mono);font-size:0.8rem;color:var(--fg-muted)}', ].join(''); var el = document.createElement('style'); @@ -118,25 +195,181 @@ }); } - function renderPump(name, detail, sparkPoints) { + // ── Month temperature chart (outdoor / supply / return) ────────── + // Multi-line SVG; line hues are fixed (read on both themes), axis chrome + // uses theme tokens. Spans whatever history exists (fills toward a month). + var TCHART = [ + { key: 'outdoor', label: 'Outdoor (BT1)', color: '#38bdf8' }, + { key: 'supply', label: 'Supply (BT2)', color: '#ef4444' }, + { key: 'ret', label: 'Return (BT3)', color: '#22c55e' }, + { key: 'extract', label: 'Extract air (BT20)', color: '#a78bfa' }, + ]; + function tempChartBlock(temps) { + if (!temps) return ''; + var lines = TCHART.map(function (s) { return { color: s.color, points: temps[s.key] || [] }; }); + var all = []; + lines.forEach(function (l) { l.points.forEach(function (p) { if (p && p.v != null) all.push(p); }); }); + if (all.length < 2) return ''; + var w = 600, h = 150, padR = 4, padL = 2, padT = 6, padB = 6; + var ts = all.map(function (p) { return p.ts; }); + var vs = all.map(function (p) { return p.v; }); + var t0 = Math.min.apply(null, ts), t1 = Math.max.apply(null, ts), tspan = (t1 - t0) || 1; + var vMin = Math.min.apply(null, vs), vMax = Math.max.apply(null, vs); + var vp = (vMax - vMin) * 0.1 || 1; vMin -= vp; vMax += vp; + var vspan = (vMax - vMin) || 1; + function X(t) { return (padL + (t - t0) / tspan * (w - padL - padR)).toFixed(1); } + function Y(v) { return (padT + (1 - (v - vMin) / vspan) * (h - padT - padB)).toFixed(1); } + // Gridlines only — axis labels are HTML (below/left) so they don't distort + // under preserveAspectRatio="none". + var grid = [vMin, (vMin + vMax) / 2, vMax].map(function (v) { + return ''; + }).join(''); + var paths = lines.map(function (l) { + var pp = l.points.filter(function (p) { return p && p.v != null; }); + if (pp.length < 2) return ''; + var d = pp.map(function (p, i) { return (i ? 'L' : 'M') + X(p.ts) + ',' + Y(p.v); }).join(' '); + return ''; + }).join(''); + var legend = TCHART.map(function (s) { + return '' + s.label + ''; + }).join(''); + var yax = [vMax, (vMin + vMax) / 2, vMin].map(function (v) { + return '' + Math.round(v) + '°'; + }).join(''); + var fmtDate = function (t) { return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; + var xticks = 4, xax = ''; + for (var xi = 0; xi < xticks; xi++) { xax += '' + escapeHtml(fmtDate(t0 + tspan * xi / (xticks - 1))) + ''; } + return '
Temperatures (°C)
' + + '
Supply / return = the heating loop (in-floor / radiators)
' + + '
' + legend + '
' + + '
' + yax + '
' + + '
' + + '
' + xax + '
' + + '
'; + } + + // Power over 24h — Total drawn / Compressor / Internal add. heat, all in kW. + // Separate from sparkline() so the ~937 tiny detail sparklines stay axis-free. + var PCHART = [ + { key: 'total', label: 'Total drawn', color: 'var(--accent-e)', scale: 1 }, + { key: 'compressor', label: 'Compressor', color: '#38bdf8', scale: 0.001 }, + { key: 'internal', label: 'Internal add. heat', color: '#f472b6', scale: 1 }, + ]; + function powerChartBlock(power) { + if (!power) return ''; + var lines = PCHART.map(function (s) { + return { color: s.color, points: (power[s.key] || []).map(function (p) { + return { ts: p.ts, v: p.v == null ? null : p.v * s.scale }; + }) }; + }); + var all = []; + lines.forEach(function (l) { l.points.forEach(function (p) { if (p && p.v != null) all.push(p); }); }); + if (all.length < 2) return ''; + var w = 600, h = 90, padR = 4, padL = 2, padT = 6, padB = 6; + var ts = all.map(function (p) { return p.ts; }); + var t0 = Math.min.apply(null, ts), t1 = Math.max.apply(null, ts), tspan = (t1 - t0) || 1; + var vMax = Math.max(Math.max.apply(null, all.map(function (p) { return p.v; })) || 1, 0.1); + var vspan = vMax || 1; + function X(t) { return (padL + (t - t0) / tspan * (w - padL - padR)).toFixed(1); } + function Y(v) { return (padT + (1 - v / vspan) * (h - padT - padB)).toFixed(1); } + var grid = [0, vMax / 2, vMax].map(function (v) { + return ''; + }).join(''); + var paths = lines.map(function (l) { + var pp = l.points.filter(function (p) { return p && p.v != null; }); + if (pp.length < 2) return ''; + var d = pp.map(function (p, i) { return (i ? 'L' : 'M') + X(p.ts) + ',' + Y(p.v); }).join(' '); + return ''; + }).join(''); + var legend = PCHART.map(function (s) { + return '' + s.label + ''; + }).join(''); + var yax = [vMax, vMax / 2, 0].map(function (v) { return '' + v.toFixed(1) + ''; }).join(''); + var fmtTime = function (t) { return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }; + var xticks = 4, xax = ''; + for (var xi = 0; xi < xticks; xi++) { xax += '' + escapeHtml(fmtTime(t0 + tspan * xi / (xticks - 1))) + ''; } + return '
Power (kW) · 24h
' + + '
' + legend + '
' + + '
' + yax + '
' + + '
' + + '
' + xax + '
' + + '
'; + } + + // ── Energy per calendar period, from the lifetime kWh counters ─── + // delta = current − value at the period boundary; null when the logged + // history doesn't reach back that far (shown as "accumulating"). + function energyDeltas(points) { + if (!points || points.length < 2) return {}; + var last = points[points.length - 1].v; + var earliest = points[0].ts; + var now = new Date(); + var startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + var dow = (now.getDay() + 6) % 7; // Monday = 0 + var startWeek = startToday - dow * 86400000; + var startMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); + var startYear = new Date(now.getFullYear(), 0, 1).getTime(); + function delta(b) { + if (earliest > b) return null; + var base = points[0].v; + for (var i = 0; i < points.length; i++) { if (points[i].ts <= b) base = points[i].v; else break; } + return Math.max(0, last - base); + } + return { today: delta(startToday), week: delta(startWeek), month: delta(startMonth), year: delta(startYear) }; + } + function energyPeriodsBlock(energy) { + if (!energy || (energy.consumed || []).length < 2) return ''; + var c = energyDeltas(energy.consumed), p = energyDeltas(energy.produced); + function cell(v) { return v == null ? 'accumulating' : (Math.round(v * 10) / 10).toLocaleString('en-US') + ' kWh'; } + var rows = [ + { k: 'today', label: 'Today' }, + { k: 'week', label: 'This week' }, + { k: 'month', label: 'This month' }, + { k: 'year', label: 'This year' }, + ].map(function (r) { + return '
' + r.label + '' + + '' + cell(c[r.k]) + '' + + '' + cell(p[r.k]) + '
'; + }).join(''); + return '
Energy per period
' + + '
ConsumedProduced
' + + rows + '
'; + } + + function tileHtml(def, m) { + var has = Object.prototype.hasOwnProperty.call(m, def.key); + var sensor = def.sensor ? ' (' + escapeHtml(def.sensor) + ')' : ''; + var info = def.info ? ' ?' : ''; + return '
' + + '' + escapeHtml(def.label) + sensor + info + '' + + '' + (has ? def.fmt(m[def.key]) : '—') + '' + + '
'; + } + + function renderPump(name, detail, temps, energy, power) { var m = metricMap(detail && detail.metrics); - var tiles = METRICS.map(function (def) { - var has = Object.prototype.hasOwnProperty.call(m, def.key); - return '
' + - '' + def.label + '' + - '' + (has ? def.fmt(m[def.key]) : '—') + '' + - '
'; + var groups = GROUPS.map(function (g) { + var tiles = g.items.map(function (def) { return tileHtml(def, m); }).join(''); + return '
' + escapeHtml(g.title) + '
' + + '
' + tiles + '
'; }).join(''); - var spark = sparkline(sparkPoints); - var sparkBlock = spark - ? '
Compressor power · 24h' + spark + '
' - : ''; - // The whole card is a button into the detail view (all signals grouped). - return '
' + + var powerBlock = powerChartBlock(power); + // Freshness: newest updated_at across the reported metrics (one poll). + var latest = 0; + ((detail && detail.metrics) || []).forEach(function (x) { + var t = x.updated_at ? Date.parse(x.updated_at) : 0; if (t > latest) latest = t; + }); + var asof = latest ? '
data as of ' + + escapeHtml(new Date(latest).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })) + '
' : ''; + // The whole card is a button into the detail view (all signals + register). + return '
' + '
' + escapeHtml(name) + '' + 'All signals →
' + - '
' + tiles + '
' + - sparkBlock + + asof + + groups + + energyPeriodsBlock(energy) + + tempChartBlock(temps) + + powerBlock + '
'; } @@ -146,27 +379,70 @@ }); } + // The live values are cheap and refresh every 30 s. Month/year history is + // comparatively expensive (SQLite/Parquet range scans, possibly over the + // owner relay), so cache those series for five minutes per heat pump. + function fetchPumpHistory(name) { + var cached = historyCache[name]; + var now = Date.now(); + if (cached && now - cached.at < HISTORY_REFRESH_MS) return Promise.resolve(cached.data); + var ser = function (metric, range, pts) { + return fetchJSON('/api/series?driver=' + encodeURIComponent(name) + '&metric=' + metric + '&range=' + range + '&points=' + pts); + }; + return Promise.all([ + ser('hp_power_w', '24h', 200), + ser('hp_outdoor_temp_c', '30d', 400), + ser('hp_supply_line_bt2', '30d', 400), + ser('hp_return_line_bt3', '30d', 400), + ser('hp_energy_consumed_kwh', '366d', 800), + ser('hp_energy_produced_kwh', '366d', 800), + ser('hp_fr_nluft_bt20', '30d', 400), + ser('hp_energy_log_current_power_consumption', '24h', 200), + ser('hp_power_internal_additional_heat', '24h', 200), + ]).then(function (parts) { + var pp = function (r) { return (r && r.points) || []; }; + var data = { + temps: { outdoor: pp(parts[1]), supply: pp(parts[2]), ret: pp(parts[3]), extract: pp(parts[6]) }, + energy: { consumed: pp(parts[4]), produced: pp(parts[5]) }, + power: { compressor: pp(parts[0]), total: pp(parts[7]), internal: pp(parts[8]) }, + }; + historyCache[name] = { at: Date.now(), data: data }; + return data; + }); + } + function refresh() { var section = document.getElementById('heating-section'); var grid = document.getElementById('heating-grid'); if (!section || !grid) return; + if (refreshInFlight) return; + refreshInFlight = true; - var ready = heatPumpDrivers - ? Promise.resolve(heatPumpDrivers) - : discover().then(function (names) { heatPumpDrivers = names; return names; }); + // Re-run discovery on first call, then periodically — so a heat-pump + // driver added while the dashboard is open shows up without a manual + // reload. (The old code cached forever; an empty result is also truthy, + // so a site that discovered before its pump reported hp_power_w stayed + // blank.) Steady-state stays cheap: between scans we only touch the + // already-known heat-pump drivers. + var nowMs = Date.now(); + var rediscover = heatPumpDrivers === null || (nowMs - lastDiscoverMs) >= DISCOVER_EVERY_MS; + var ready = rediscover + ? discover().then(function (names) { heatPumpDrivers = names; lastDiscoverMs = nowMs; return names; }) + : Promise.resolve(heatPumpDrivers); ready.then(function (names) { if (!names || names.length === 0) { section.hidden = true; return; } - // Fetch detail + 24h power series for each heat pump in parallel. + // Refresh live detail every cycle; reuse bounded-age history. return Promise.all(names.map(function (n) { return Promise.all([ fetchJSON('/api/drivers/' + encodeURIComponent(n)), - fetchJSON('/api/series?driver=' + encodeURIComponent(n) + '&metric=hp_power_w&range=24h&points=200'), + fetchPumpHistory(n), ]).then(function (parts) { - return { name: n, detail: parts[0], series: (parts[1] && parts[1].points) || [] }; + var h = parts[1] || {}; + return { name: n, detail: parts[0], temps: h.temps, energy: h.energy, power: h.power }; }); })).then(function (pumps) { var live = pumps.filter(function (p) { return p.detail && isHeatPump(p.detail); }); @@ -174,9 +450,13 @@ injectStyles(); section.hidden = false; grid.innerHTML = live.map(function (p) { - return renderPump(p.name, p.detail, p.series); + return renderPump(p.name, p.detail, p.temps, p.energy, p.power); }).join(''); }); + }).then(function () { + refreshInFlight = false; + }, function () { + refreshInFlight = false; }); } @@ -222,10 +502,17 @@ '.ftw-hpd-title{font-family:var(--mono);font-size:0.74rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--fg-muted)}', '.ftw-hpd-close{background:none;border:1px solid var(--line);color:var(--fg);border-radius:6px;cursor:pointer;font-size:1rem;line-height:1;padding:4px 9px}', '.ftw-hpd-group{margin:16px 0 4px;font-family:var(--mono);font-size:0.68rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--accent-e)}', - '.ftw-hpd-row{display:grid;grid-template-columns:1fr auto 120px;gap:10px 14px;align-items:center;padding:5px 0;border-bottom:1px solid var(--line-soft,var(--line))}', + '.ftw-hpd-item{padding:6px 0;border-bottom:1px solid var(--line-soft,var(--line))}', + '.ftw-hpd-row{display:grid;grid-template-columns:1fr auto auto 150px;gap:10px 14px;align-items:center}', + '.ftw-hpd-sub{font-family:var(--mono);font-size:0.62rem;color:var(--fg-muted);margin:-6px 0 12px;letter-spacing:0.02em}', + '.ftw-hpd-explain{font-size:0.74rem;color:var(--fg-muted);line-height:1.35;margin-top:3px;max-width:66ch}', + '.ftw-hpd-explain::before{content:"↳ ";opacity:0.55}', '.ftw-hpd-label{color:var(--fg-muted);font-size:0.85rem}', + '.ftw-hpd-reg{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:0.76rem;color:var(--fg-muted);text-align:right;opacity:0.8}', '.ftw-hpd-val{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--fg);text-align:right}', - '.ftw-hpd-spark svg{width:120px;height:24px;display:block}', + '.ftw-hpd-spark{display:flex;align-items:center;gap:5px}', + '.ftw-hpd-spark svg{flex:1;min-width:0;height:24px;display:block}', + '.ftw-hpd-yr{display:flex;flex-direction:column;justify-content:space-between;height:22px;min-width:30px;text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:0.52rem;line-height:1;color:var(--fg-muted)}', '.ftw-hpd-empty{color:var(--fg-muted);font-family:var(--mono);font-size:0.82rem}', ].join(''); var el = document.createElement('style'); @@ -241,6 +528,28 @@ } function onDetailKey(e) { if (e.key === 'Escape') closeDetail(); } + // Curated explanations from the card GROUPS, keyed by metric name. Built + // once, lazily (GROUPS is defined above at module scope). + var _infoByKey = null; + function infoForKey(key) { + if (!_infoByKey) { + _infoByKey = {}; + GROUPS.forEach(function (g) { + (g.items || []).forEach(function (it) { if (it.info) _infoByKey[it.key] = it.info; }); + }); + } + return _infoByKey[key] || ''; + } + + // Compact number for the sparkline min/max scale (keeps the 150px column tight). + function sparkNum(v) { + var a = Math.abs(v); + if (a >= 10000) return Math.round(v / 1000) + 'k'; + if (a >= 1000) return (v / 1000).toFixed(1) + 'k'; + if (a >= 10) return Math.round(v).toString(); + return v.toFixed(1); + } + function openDetail(name) { injectDetailStyles(); closeDetail(); @@ -250,6 +559,7 @@ backdrop.innerHTML = '
' + '
Heat pump · ' + escapeHtml(name) + '' + '
' + + '
Trend column = last 24 h · the two small figures are its min / max over that window.
' + '
Loading signals…
'; backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeDetail(); }); backdrop.querySelector('.ftw-hpd-close').addEventListener('click', closeDetail); @@ -275,27 +585,43 @@ rows.sort(function (a, b) { return a.name < b.name ? -1 : 1; }); html += '
' + escapeHtml(g) + '
'; rows.forEach(function (m) { - html += '
' + + var explain = infoForKey(m.name) || m.title || ''; + html += '
' + + '
' + '' + escapeHtml(prettyLabel(m.name)) + '' + + '' + (m.register ? escapeHtml(String(m.register)) : '—') + '' + '' + escapeHtml(fmtValue(m.value, m.unit)) + '' + '' + + '
' + + (explain ? '
' + escapeHtml(explain) + '
' : '') + '
'; }); }); body.innerHTML = html; - // Lazily fill sparklines (one /api/series per metric) — values already - // shown, so a slow series fetch never blocks the table. - metrics.forEach(function (m) { + // Fetch trends only for the curated dashboard signals. A NIBE driver can + // expose ~980 points; issuing one series query per point here could flood + // a Pi (and the remote relay) with nearly a thousand concurrent requests. + metrics.filter(function (m) { return !!infoForKey(m.name); }).forEach(function (m) { fetchJSON('/api/series?driver=' + encodeURIComponent(name) + '&metric=' + encodeURIComponent(m.name) + '&range=24h&points=120') .then(function (s) { var slot = body.querySelector('.ftw-hpd-spark[data-spark-metric="' + (window.CSS && CSS.escape ? CSS.escape(m.name) : m.name) + '"]'); - if (slot) slot.innerHTML = sparkline((s && s.points) || []); + if (!slot) return; + var pp = (s && s.points) || []; + var vals = pp.map(function (p) { return p.v; }).filter(function (v) { return v != null; }); + var yr = vals.length + ? '' + escapeHtml(sparkNum(Math.max.apply(null, vals))) + '' + + '' + escapeHtml(sparkNum(Math.min.apply(null, vals))) + '' + : ''; + slot.innerHTML = sparkline(pp) + yr; }); }); }); } function onGridClick(e) { + // The ? help icons explain a metric in place (native tooltip) — a click on + // one must NOT navigate into the all-signals detail. + if (e.target.closest && e.target.closest('.ftw-hp-i')) return; var card = e.target.closest && e.target.closest('.ftw-hp-clickable'); if (card && card.dataset.hpDriver) openDetail(card.dataset.hpDriver); } diff --git a/web/index.html b/web/index.html index 263b3ce8..7534e1f2 100644 --- a/web/index.html +++ b/web/index.html @@ -50,7 +50,7 @@ - +