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:
@@ -0,0 +1,5 @@
|
||||
# secrets
|
||||
nginx/.htpasswd
|
||||
# personal data export
|
||||
history.csv
|
||||
.env
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
Vendored
+1
@@ -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);
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Executable
+20
@@ -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."
|
||||
Reference in New Issue
Block a user