Add heatpump compressor cycle analysis web app

Static page that plots Home Assistant on/off history of the heatpump
compressor with pan/zoom, per-phase hover durations and statistics
(min/max/mean/median, total on-time, duty cycle) for the visible range.
English/German UI. Served by nginx in Docker with basic auth, CSP and
a healthcheck; Plotly is vendored locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 10:21:07 +02:00
co-authored by Claude Opus 5.5
commit 77a594c3af
12 changed files with 704 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# secrets
nginx/.htpasswd
# personal data export
history.csv
.env
+106
View File
@@ -0,0 +1,106 @@
# Heatpump cycle analysis
A small static web app that visualises when a heat pump compressor is running,
based on a Home Assistant history export (`binary_sensor.*` on/off states).
- Interactive timeline (pan, zoom, mouse-wheel, quick ranges 6 h / 24 h / 3 d / 7 d)
- Hover an on-phase to see its start, end and duration
- Statistics for the visible range: number of phases, min / max / mean / median
duration, total on-time, duty cycle, mean pause between phases
- Table of all on-phases in the visible range
- English / German UI (auto-detected from the browser, switchable, remembered)
- Light / dark mode following the OS setting
- Served by nginx in Docker, protected with HTTP basic auth; no external requests
(Plotly is vendored in `html/vendor/`)
## Quick start
```sh
# 1. put your export next to docker-compose.yml
cp /path/to/export.csv history.csv
# 2. create the login (prompts for the password)
./set-password.sh admin
# 3. start
docker compose up -d
```
Open <http://localhost:8080> and log in.
> Run `set-password.sh` **before** the first `docker compose up`. If
> `nginx/.htpasswd` does not exist, Docker creates an empty directory in its
> place and nginx rejects every login. The script removes that directory
> automatically; restart the container afterwards.
### Configuration
Set via environment or a `.env` file next to `docker-compose.yml`:
| Variable | Default | Meaning |
|-----------------|-----------------|-----------------------------------|
| `HEATPUMP_PORT` | `8080` | Host port |
| `HEATPUMP_CSV` | `./history.csv` | CSV file served as `history.csv` |
Change the password with `./set-password.sh <user>` followed by
`docker compose restart`. Replacing the CSV needs no restart; reload the page.
## CSV format
Home Assistant's history download (*History → ⋮ → Download data*):
```csv
entity_id,state,last_changed
binary_sensor.heatpump_compressor,on,2026-09-17T03:00:19.068Z
binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z
```
Only the `state` and `last_changed` columns are used; column order doesn't
matter. A different CSV can also be opened ad hoc with **Load CSV…**. It is
parsed in the browser and not uploaded.
### How phases are computed
- An on-phase starts at the first `on` and ends at the next state that is not
`on` (`off`, `unknown`, `unavailable`, …). Repeated `on` rows are merged.
- A phase still running at the end of the data is not counted.
- Statistics include the phases that **start** inside the visible range. Total
on-time and duty cycle are clipped to the range.
- `unknown` / `unavailable` periods are shown as gaps in the timeline.
- All times are shown in the browser's local time zone.
## Project layout
```
docker-compose.yml nginx container, volumes, healthcheck
set-password.sh writes nginx/.htpasswd (apr1 hash via openssl)
nginx/default.conf basic auth, gzip, CSV alias, /healthz
nginx/security-headers.conf CSP and other headers, included per location
html/index.html markup
html/style.css styles (light/dark tokens)
html/i18n.js English / German strings
html/app.js CSV parsing, statistics, chart, UI wiring
html/vendor/ Plotly 2.35.2 + German locale
```
`nginx/.htpasswd` and `history.csv` are git-ignored.
## Operations
- **Health:** `GET /healthz` (no auth) returns `ok`; used by the Docker
healthcheck.
- **TLS:** the container serves plain HTTP. Basic auth sends credentials in
clear text, so outside a trusted LAN put it behind a TLS-terminating reverse
proxy (Traefik, Caddy, nginx, …).
- **Updating Plotly:**
```sh
v=2.35.2
curl -fLo html/vendor/plotly.min.js https://cdn.jsdelivr.net/npm/plotly.js-dist-min@$v/plotly.min.js
curl -fLo html/vendor/plotly-locale-de.js https://cdn.jsdelivr.net/npm/plotly.js@$v/dist/plotly-locale-de.js
```
## Adding a language
Add an entry to `I18N` in `html/i18n.js` (copy `en`, translate, set `locale`),
add a `<button data-lang="xx">` in `index.html`, and, for translated chart
controls, vendor `plotly-locale-xx.js` and include it in `index.html`.
+17
View File
@@ -0,0 +1,17 @@
services:
heatpump:
image: nginx:1.29-alpine
restart: unless-stopped
ports:
- "${HEATPUMP_PORT:-8080}:80"
volumes:
- ./html:/usr/share/nginx/html:ro
- ${HEATPUMP_CSV:-./history.csv}:/data/history.csv:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/security-headers.conf:/etc/nginx/security-headers.conf:ro
- ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"]
interval: 30s
timeout: 5s
retries: 3
+302
View File
@@ -0,0 +1,302 @@
'use strict';
const LANG_KEY = 'heatpump.lang';
const HOUR = 3600e3;
const state = {
lang: 'en',
events: [], // [{ state, t }] sorted by time (ms)
phases: [], // [{ start, end, dur }] completed on-phases (ms)
dataMin: 0,
dataMax: 0,
range: null, // [from, to] in ms, or null = everything
source: '',
error: null, // { key, params } so it can be re-rendered on language change
};
const $ = id => document.getElementById(id);
const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
// ---------- i18n ----------
function t(key, params = {}) {
const s = I18N[state.lang][key] ?? I18N.en[key] ?? key;
return typeof s === 'string' ? s.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? '') : s;
}
function detectLang() {
try {
const saved = localStorage.getItem(LANG_KEY);
if (saved in I18N) return saved;
} catch { /* storage unavailable */ }
return (navigator.language || '').toLowerCase().startsWith('de') ? 'de' : 'en';
}
function setLang(lang) {
state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch { /* storage unavailable */ }
document.documentElement.lang = lang;
document.title = t('title');
document.querySelectorAll('[data-i18n]').forEach(el => {
// only the note contains markup, and it comes from our own dictionary
if (el.dataset.i18n === 'note') el.innerHTML = t('note');
else el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
render();
}
// ---------- formatting ----------
const pad = n => String(n).padStart(2, '0');
// Local-time string for Plotly: it treats date strings without zone as-is,
// so the axis shows the viewer's local time instead of UTC.
function plotlyDate(ms) {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const fromPlotlyDate = v => typeof v === 'number' ? v : new Date(String(v).replace(' ', 'T')).getTime();
const fmtDateTime = ms => new Date(ms).toLocaleString(t('locale'),
{ weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const fmtTime = ms => new Date(ms).toLocaleTimeString(t('locale'), { hour: '2-digit', minute: '2-digit' });
const fmtTableDate = ms => new Date(ms).toLocaleString(t('locale'),
{ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
function fmtDur(ms) {
if (ms == null || !Number.isFinite(ms)) return '–';
const u = t('units');
const s = Math.round(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60);
return h ? `${h}${u.h} ${pad(m)}${u.m}` : `${m}${u.m} ${pad(s % 60)}${u.s}`;
}
function fmtSpan(ms) {
const u = t('units');
const m = Math.round(ms / 60000), d = Math.floor(m / 1440), h = Math.floor(m % 1440 / 60);
if (d) return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
return h ? `${h} ${u.h} ${pad(m % 60)} ${u.m}` : `${m} ${u.m}`;
}
const fmtPercent = x => new Intl.NumberFormat(t('locale'),
{ style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(x);
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
// ---------- data ----------
class CsvError extends Error {
constructor(key) { super(key); this.key = key; }
}
// Parses a Home Assistant history export (entity_id,state,last_changed).
function parseCsv(text) {
const lines = text.replace(/^/, '').trim().split(/\r?\n/);
const cells = line => line.split(',').map(c => c.trim().replace(/^"(.*)"$/, '$1'));
const head = cells(lines.shift() || '');
const iState = head.indexOf('state'), iTime = head.indexOf('last_changed');
if (iState < 0 || iTime < 0) throw new CsvError('errColumns');
const events = lines
.map(cells)
.map(c => ({ state: c[iState], t: Date.parse(c[iTime]) }))
.filter(e => e.state && Number.isFinite(e.t))
.sort((a, b) => a.t - b.t);
if (!events.length) throw new CsvError('errEmpty');
// An on-phase starts at the first "on" and ends at the next non-"on" state.
const phases = [];
let start = null;
for (const e of events) {
if (e.state === 'on') {
if (start === null) start = e.t;
} else if (start !== null) {
phases.push({ start, end: e.t, dur: e.t - start });
start = null;
}
}
return { events, phases };
}
function load(text, source) {
try {
const { events, phases } = parseCsv(text);
Object.assign(state, {
events, phases, source, error: null, range: null,
dataMin: events[0].t, dataMax: events[events.length - 1].t,
});
} catch (err) {
if (!(err instanceof CsvError)) throw err;
state.error = { key: err.key };
}
render();
}
function stats(from, to) {
const inRange = state.phases.filter(p => p.start >= from && p.start <= to);
const d = inRange.map(p => p.dur).sort((a, b) => a - b);
const n = d.length;
const mean = arr => arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : null;
const onTime = state.phases.reduce((s, p) => s + Math.max(0, Math.min(p.end, to) - Math.max(p.start, from)), 0);
return {
inRange, n,
min: n ? d[0] : null,
max: n ? d[n - 1] : null,
mean: mean(d),
median: n ? (n % 2 ? d[(n - 1) / 2] : (d[n / 2 - 1] + d[n / 2]) / 2) : null,
onTime,
duty: to > from ? onTime / (to - from) : null,
meanGap: mean(inRange.slice(1).map((p, i) => p.start - inRange[i].end)),
};
}
// ---------- rendering ----------
const tilesHtml = tiles => tiles.map(([key, value, cls = '']) =>
`<div class="tile"><div class="k">${escapeHtml(t(key))}</div><div class="v ${cls}">${escapeHtml(value)}</div></div>`).join('');
function render() {
const src = $('source');
if (state.error) {
src.textContent = t(state.error.key, state.error.params);
src.className = 'sub error';
} else {
src.textContent = state.source || t('loading');
src.className = 'sub';
}
if (!state.events.length) return;
$('dataTiles').innerHTML = tilesHtml([
['from', fmtDateTime(state.dataMin), 'sm'],
['to', fmtDateTime(state.dataMax), 'sm'],
['span', fmtSpan(state.dataMax - state.dataMin)],
['events', state.events.length],
['onPhases', state.phases.length],
]);
drawChart();
renderStats();
}
function drawChart() {
const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line');
// Step line for context; unknown/unavailable states become gaps.
const stepTrace = {
type: 'scatter', mode: 'lines', hoverinfo: 'skip', connectgaps: false,
x: state.events.map(e => plotlyDate(e.t)),
y: state.events.map(e => e.state === 'on' ? 1 : e.state === 'off' ? 0 : null),
line: { shape: 'hv', width: 1.5, color: accent },
};
// One bar per on-phase spanning its real duration; it carries the hover tooltip.
const phaseTrace = {
type: 'bar',
x: state.phases.map(p => plotlyDate(p.start + p.dur / 2)),
y: state.phases.map(() => 1),
width: state.phases.map(p => p.dur),
customdata: state.phases.map(p => [fmtDateTime(p.start), fmtTime(p.end), fmtDur(p.dur)]),
hovertemplate: '<b>%{customdata[2]}</b><br>%{customdata[0]} → %{customdata[1]}<extra></extra>',
marker: { color: css('--accent-fill'), line: { width: 0 } },
};
const axis = { gridcolor: line, zerolinecolor: line, linecolor: line, tickfont: { color: ink2 } };
const layout = {
margin: { l: 40, r: 12, t: 8, b: 36 },
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'system-ui, sans-serif', color: ink2, size: 12 },
showlegend: false, dragmode: 'zoom', hovermode: 'x', hoverdistance: 30, bargap: 0,
hoverlabel: { bgcolor: css('--surface'), bordercolor: line, font: { color: ink } },
xaxis: {
...axis, type: 'date',
...(state.range ? { range: state.range.map(plotlyDate) } : { autorange: true }),
},
yaxis: {
...axis, fixedrange: true, range: [-0.05, 1.15],
tickvals: [0, 1], ticktext: [t('off'), t('on')],
},
};
const config = {
responsive: true, displaylogo: false, scrollZoom: true, locale: state.lang,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d'],
};
const chart = $('chart');
Plotly.react(chart, [stepTrace, phaseTrace], layout, config);
if (!chart.dataset.bound) {
chart.dataset.bound = '1';
chart.on('plotly_relayout', onRelayout);
}
}
// Keeps the statistics in sync with zoom / pan / reset in the chart.
function onRelayout(ev) {
if (ev['xaxis.autorange']) state.range = null;
else if (ev['xaxis.range[0]'] !== undefined) state.range = [ev['xaxis.range[0]'], ev['xaxis.range[1]']].map(fromPlotlyDate);
else if (ev['xaxis.range']) state.range = ev['xaxis.range'].map(fromPlotlyDate);
else return;
renderStats();
}
function setRange(range) {
state.range = range;
Plotly.relayout($('chart'), range ? { 'xaxis.range': range.map(plotlyDate) } : { 'xaxis.autorange': true });
renderStats();
}
function renderStats() {
const [from, to] = state.range || [state.dataMin, state.dataMax];
const s = stats(from, to);
$('range').textContent = `${fmtDateTime(from)} → ${fmtDateTime(to)} (${fmtSpan(to - from)})`;
$('tiles').innerHTML = tilesHtml([
['phases', s.n],
['min', fmtDur(s.min)],
['max', fmtDur(s.max)],
['mean', fmtDur(s.mean)],
['median', fmtDur(s.median)],
['totalOn', fmtDur(s.onTime)],
['dutyCycle', s.duty == null ? '–' : fmtPercent(s.duty)],
['meanGap', fmtDur(s.meanGap)],
]);
const first = state.phases.indexOf(s.inRange[0]);
$('rows').innerHTML = s.inRange.map((p, i) => {
const prev = state.phases[first + i - 1];
return `<tr><td>${i + 1}</td><td>${fmtTableDate(p.start)}</td><td>${fmtTableDate(p.end)}</td>` +
`<td class="num">${fmtDur(p.dur)}</td><td class="num">${prev ? fmtDur(p.start - prev.end) : '–'}</td></tr>`;
}).join('');
}
// ---------- wiring ----------
document.querySelectorAll('[data-lang]').forEach(btn =>
btn.addEventListener('click', () => setLang(btn.dataset.lang)));
document.querySelectorAll('[data-span]').forEach(btn => btn.addEventListener('click', () => {
if (!state.events.length) return;
const span = btn.dataset.span;
if (span === 'all') return setRange(null);
const to = state.range ? state.range[1] : state.dataMax;
setRange([to - Number(span) * HOUR, to]);
}));
$('file').addEventListener('change', async e => {
const file = e.target.files[0];
e.target.value = ''; // allow re-selecting the same file
if (file) load(await file.text(), file.name);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
setLang(detectLang());
fetch('history.csv', { cache: 'no-cache' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(text => load(text, 'history.csv'))
.catch(err => {
state.error = { key: 'errFetch', params: { status: err.message } };
render();
});
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// UI strings. Keys used in index.html via data-i18n and in app.js via t().
const I18N = {
en: {
locale: 'en-GB',
title: 'Heatpump compressor cycles',
loading: 'loading…',
dataHeading: 'Data',
rangeHeading: 'Selected range',
all: 'All',
loadCsv: 'Load CSV…',
phaseList: 'On-phases in selected range',
start: 'Start',
end: 'End',
duration: 'Duration',
gapBefore: 'Gap before',
from: 'From',
to: 'To',
span: 'Span',
events: 'Events',
onPhases: 'On-phases',
phases: 'Phases',
min: 'Min',
max: 'Max',
mean: 'Mean',
median: 'Median',
totalOn: 'Total on',
dutyCycle: 'Duty cycle',
meanGap: 'Mean off-gap',
on: 'on',
off: 'off',
units: { d: 'd', h: 'h', m: 'm', s: 's' },
errColumns: 'CSV needs the columns "state" and "last_changed".',
errEmpty: 'CSV contains no valid rows.',
errFetch: 'Could not load history.csv ({status}) – use “Load CSV…”.',
note: 'Hover a phase to see its duration. Drag to zoom, shift+drag or the pan tool to pan, ' +
'mouse wheel to zoom, double-click to reset. Statistics use the on-phases that <em>start</em> ' +
'within the visible range; total on-time and duty cycle are clipped to the range. ' +
'A phase ends at the first state that is not "on" (off, unknown, unavailable); ' +
'a phase still running at the end of the data is excluded. Times are shown in your local time zone.',
},
de: {
locale: 'de-DE',
title: 'Wärmepumpe – Verdichterzyklen',
loading: 'lädt…',
dataHeading: 'Daten',
rangeHeading: 'Ausgewählter Zeitraum',
all: 'Alles',
loadCsv: 'CSV laden…',
phaseList: 'Laufphasen im ausgewählten Zeitraum',
start: 'Start',
end: 'Ende',
duration: 'Dauer',
gapBefore: 'Pause davor',
from: 'Von',
to: 'Bis',
span: 'Zeitraum',
events: 'Ereignisse',
onPhases: 'Laufphasen',
phases: 'Phasen',
min: 'Min',
max: 'Max',
mean: 'Mittelwert',
median: 'Median',
totalOn: 'Laufzeit gesamt',
dutyCycle: 'Einschaltanteil',
meanGap: 'Ø Pause',
on: 'an',
off: 'aus',
units: { d: 'T', h: 'h', m: 'min', s: 's' },
errColumns: 'Die CSV benötigt die Spalten "state" und "last_changed".',
errEmpty: 'Die CSV enthält keine gültigen Zeilen.',
errFetch: 'history.csv konnte nicht geladen werden ({status}) – bitte „CSV laden…“ verwenden.',
note: 'Für die Dauer über eine Phase fahren. Ziehen zum Zoomen, Umschalt+Ziehen oder das Verschieben-Werkzeug ' +
'zum Verschieben, Mausrad zum Zoomen, Doppelklick zum Zurücksetzen. Die Statistik berücksichtigt Laufphasen, ' +
'die im sichtbaren Zeitraum <em>beginnen</em>; Laufzeit gesamt und Einschaltanteil werden auf den Zeitraum begrenzt. ' +
'Eine Phase endet beim ersten Zustand ungleich „on“ (off, unknown, unavailable); ' +
'eine am Datenende noch laufende Phase wird nicht gezählt. Zeiten in lokaler Zeitzone.',
},
};
+57
View File
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heatpump Cycles</title>
<link rel="stylesheet" href="style.css">
<script src="vendor/plotly.min.js" defer></script>
<script src="vendor/plotly-locale-de.js" defer></script>
<script src="i18n.js" defer></script>
<script src="app.js" defer></script>
</head>
<body>
<main>
<header>
<h1 data-i18n="title">Heatpump compressor cycles</h1>
<div class="header-right">
<span class="sub" id="source" data-i18n="loading">loading…</span>
<div class="lang" role="group" aria-label="Language">
<button type="button" data-lang="en">EN</button>
<button type="button" data-lang="de">DE</button>
</div>
</div>
</header>
<h2 data-i18n="dataHeading">Data</h2>
<div class="tiles" id="dataTiles"></div>
<div class="controls">
<button type="button" data-span="6">6 h</button>
<button type="button" data-span="24">24 h</button>
<button type="button" data-span="72">3 d</button>
<button type="button" data-span="168">7 d</button>
<button type="button" data-span="all" data-i18n="all">All</button>
<label class="file"><span data-i18n="loadCsv">Load CSV…</span> <input type="file" id="file" accept=".csv,text/csv"></label>
<span class="range" id="range"></span>
</div>
<div class="card"><div id="chart"></div></div>
<h2 data-i18n="rangeHeading">Selected range</h2>
<div class="tiles" id="tiles"></div>
<details>
<summary data-i18n="phaseList">On-phases in selected range</summary>
<table>
<thead><tr>
<th>#</th><th data-i18n="start">Start</th><th data-i18n="end">End</th>
<th class="num" data-i18n="duration">Duration</th><th class="num" data-i18n="gapBefore">Gap before</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</details>
<p class="note" data-i18n="note"></p>
</main>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
:root {
--bg: #f7f7f5; --surface: #ffffff; --ink: #1b1b1f; --ink-2: #55565e; --muted: #8a8b93;
--line: #e4e4e0; --accent: #2a6fdb; --accent-fill: rgba(42, 111, 219, .18); --error: #c0392b;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141417; --surface: #1d1e22; --ink: #ececf0; --ink-2: #b1b2ba; --muted: #7d7e87;
--line: #2e2f35; --accent: #6b9ff0; --accent-fill: rgba(107, 159, 240, .22); --error: #ff7b6b;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 40px; }
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
h1 { font-size: 20px; margin: 0; font-weight: 600; }
h2 {
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--muted); margin: 18px 0 -6px;
}
.header-right { display: flex; gap: 14px; align-items: center; }
.sub { color: var(--ink-2); font-size: 13px; }
.error { color: var(--error); }
button, label.file {
font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
}
button:hover, label.file:hover { border-color: var(--accent); }
label.file:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
label.file input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.lang { display: flex; }
.lang button { border-radius: 0; padding: 4px 9px; }
.lang button:first-child { border-radius: 6px 0 0 6px; }
.lang button:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
.lang button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 14px 0; }
.range { color: var(--ink-2); font-size: 13px; font-variant-numeric: tabular-nums; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
#chart { width: 100%; height: 340px; }
/* the tooltip already contains the time; hide Plotly's extra x-axis hover label */
#chart .hoverlayer .axistext { display: none; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 14px 0; }
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
.tile .k { color: var(--ink-2); font-size: 12px; }
.tile .v { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 2px; }
.tile .v.sm { font-size: 17px; }
details { margin-top: 14px; }
summary { cursor: pointer; color: var(--ink-2); }
table { border-collapse: collapse; width: 100%; margin-top: 8px; font-variant-numeric: tabular-nums; font-size: 13px; }
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--ink-2); font-weight: 500; }
th.num, td.num { text-align: right; }
.note { color: var(--muted); font-size: 12px; margin-top: 8px; }
+1
View File
@@ -0,0 +1 @@
var locale={moduleType:"locale",name:"de",dictionary:{Autoscale:"Automatische Skalierung","Box Select":"Rechteckauswahl","Click to enter Colorscale title":"Klicken, um den Farbskalatitel einzugeben","Click to enter Component A title":"Klicken, um den Titel der Komponente A einzugeben","Click to enter Component B title":"Klicken, um den Titel der Komponente B einzugeben","Click to enter Component C title":"Klicken, um den Titel der Komponente C einzugeben","Click to enter Plot title":"Klicken, um den Titel des Graphen einzugeben","Click to enter X axis title":"Klicken, um den Titel der X-Achse einzugeben","Click to enter Y axis title":"Klicken, um den Titel der Y-Achse einzugeben","Compare data on hover":"\xdcber die Daten fahren, um sie zu vergleichen","Double-click on legend to isolate one trace":"Daten isolieren durch Doppelklick in der Legende","Double-click to zoom back out":"Herauszoomen durch Doppelklick","Download plot as a png":"Graphen als PNG herunterladen","Download plot":"Graphen herunterladen","Edit in Chart Studio":"Im Chart Studio bearbeiten","IE only supports svg. Changing format to svg.":"IE unterst\xfctzt nur SVG-Dateien. Format wird zu SVG gewechselt.","Lasso Select":"Lassoauswahl","Orbital rotation":"Orbitalrotation",Pan:"Verschieben","Produced with Plotly.js":"Erstellt mit Plotly.js",Reset:"Zur\xfccksetzen","Reset axes":"Achsen zur\xfccksetzen","Reset camera to default":"Kamera auf Standard zur\xfccksetzen","Reset camera to last save":"Kamera auf letzte Speicherung zur\xfccksetzen","Reset view":"Ansicht zur\xfccksetzen","Reset views":"Ansichten zur\xfccksetzen","Show closest data on hover":"Zeige n\xe4heste Daten beim \xdcberfahren","Snapshot succeeded":"Snapshot erfolgreich","Sorry, there was a problem downloading your snapshot!":"Es gab ein Problem beim Herunterladen des Snapshots","Taking snapshot - this may take a few seconds":"Erstelle einen Snapshot - dies kann einige Sekunden dauern",Zoom:"Zoom","Zoom in":"Hineinzoomen","Zoom out":"Herauszoomen","close:":"Schluss:",trace:"Datenspur","lat:":"Lat.:","lon:":"Lon.:","q1:":"q1:","q3:":"q3:","source:":"Quelle:","target:":"Ziel:","lower fence:":"Untere Schranke:","upper fence:":"Obere Schranke:","max:":"Max.:","mean \xb1 \u03c3:":"Mittelwert \xb1 \u03c3:","mean:":"Mittelwert:","median:":"Median:","min:":"Min.:","Turntable rotation":"Drehscheibenorbit","Toggle Spike Lines":"Bezugslinien an-/abschalten","open:":"Er\xf6ffnung:","high:":"H\xf6chstkurs:","low:":"Tiefstkurs:","Toggle show closest data on hover":"Anzeige der n\xe4hesten Daten an-/abschalten","incoming flow count:":"Anzahl eingehender Verbindungen:","outgoing flow count:":"Anzahl ausgehender Verbindungen:","kde:":"Dichte:"},format:{days:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],shortDays:["So","Mo","Di","Mi","Do","Fr","Sa"],months:["Januar","Februar","M\xe4rz","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],shortMonths:["Jan","Feb","M\xe4r","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],date:"%d.%m.%Y",decimal:",",thousands:"."}};"undefined"==typeof Plotly?(window.PlotlyLocales=window.PlotlyLocales||[],window.PlotlyLocales.push(locale)):Plotly.register(locale);
+8
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
charset utf-8;
gzip on;
gzip_comp_level 6;
gzip_types text/css text/csv application/javascript;
auth_basic "Heatpump";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
try_files $uri $uri/ =404;
include /etc/nginx/security-headers.conf;
}
# the CSV is mounted separately so it can be replaced without touching the app
location = /history.csv {
alias /data/history.csv;
default_type text/csv;
include /etc/nginx/security-headers.conf;
}
# unauthenticated liveness probe for the container healthcheck
location = /healthz {
auth_basic off;
access_log off;
default_type text/plain;
return 200 "ok\n";
}
}
+7
View File
@@ -0,0 +1,7 @@
# Included per location: a location's own add_header drops inherited ones.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# Plotly injects inline styles and exports PNGs via data:/blob: URLs
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
add_header Cache-Control "no-cache" always;
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Creates nginx/.htpasswd for HTTP basic auth.
# Usage: ./set-password.sh <user> [password] (prompts for the password if omitted)
set -eu
user="${1:?usage: $0 <user> [password]}"
case "$user" in *:*) echo "user name must not contain ':'" >&2; exit 1 ;; esac
if [ $# -ge 2 ]; then
hash=$(openssl passwd -apr1 "$2")
else
hash=$(openssl passwd -apr1)
fi
file="$(dirname "$0")/nginx/.htpasswd"
[ -d "$file" ] && rmdir "$file" # docker creates a directory if the file was missing on first start
umask 077
printf '%s:%s\n' "$user" "$hash" > "$file"
chmod 644 "$file" # nginx workers must be able to read it
echo "Wrote $file for user '$user'. Run 'docker compose restart' if the container is already running."