Build container image in CI, bake in datasets, configure auth via env
Build image / build (push) Successful in 3m18s

- Dockerfile bakes the app and every CSV in data/ into an nginx image
- UI discovers data/*.csv via nginx JSON autoindex and offers a
  dropdown when there is more than one dataset
- docker-entrypoint.d/40-basic-auth.sh creates .htpasswd from
  BASIC_AUTH_USER / BASIC_AUTH_PASSWORD(_FILE) and refuses to start
  without credentials; replaces set-password.sh
- Gitea Actions workflow builds and pushes
  registry.traberph.de/public/interval_viz (latest, sha-*, semver)
- Escape resets the chart zoom

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 11:12:12 +02:00
co-authored by Claude Opus 5.5
parent 77a594c3af
commit e70d15f861
17 changed files with 431 additions and 122 deletions
+70 -14
View File
@@ -1,6 +1,8 @@
'use strict';
const LANG_KEY = 'heatpump.lang';
const DATASET_KEY = 'heatpump.dataset';
const DATA_DIR = 'data/';
const HOUR = 3600e3;
const state = {
@@ -10,7 +12,9 @@ const state = {
dataMin: 0,
dataMax: 0,
range: null, // [from, to] in ms, or null = everything
source: '',
datasets: [], // CSV file names in data/
source: '', // name of the loaded dataset or local file
localFile: false, // source was opened via "Load CSV…"
error: null, // { key, params } so it can be re-rendered on language change
};
@@ -42,6 +46,7 @@ function setLang(lang) {
if (el.dataset.i18n === 'note') el.innerHTML = t('note');
else el.textContent = t(el.dataset.i18n);
});
$('dataset').setAttribute('aria-label', t('dataset'));
document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
render();
}
@@ -119,11 +124,11 @@ function parseCsv(text) {
return { events, phases };
}
function load(text, source) {
function load(text, source, localFile = false) {
try {
const { events, phases } = parseCsv(text);
Object.assign(state, {
events, phases, source, error: null, range: null,
events, phases, source, localFile, error: null, range: null,
dataMin: events[0].t, dataMax: events[events.length - 1].t,
});
} catch (err) {
@@ -157,12 +162,14 @@ 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() {
renderDatasetSelect();
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');
// with a dropdown the selected entry already names the source
src.textContent = $('dataset').hidden ? state.source || t('loading') : '';
src.className = 'sub';
}
if (!state.events.length) return;
@@ -178,6 +185,17 @@ function render() {
renderStats();
}
function renderDatasetSelect() {
const select = $('dataset');
select.hidden = state.datasets.length < 2;
if (select.hidden) return;
const option = (value, label, extra = '') =>
`<option value="${escapeHtml(value)}"${extra}>${escapeHtml(label)}</option>`;
select.innerHTML = state.datasets.map(name => option(name, name)).join('') +
(state.localFile ? option('', `${t('localFile')}: ${state.source}`, ' disabled') : '');
select.value = state.localFile ? '' : state.source;
}
function drawChart() {
const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line');
@@ -283,20 +301,58 @@ document.querySelectorAll('[data-span]').forEach(btn => btn.addEventListener('cl
$('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);
if (file) load(await file.text(), file.name, true);
});
// Escape resets the zoom, like a double-click in the chart
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented || !state.range) return;
if (e.target.closest('select, input')) return; // let open controls handle their own Escape
setRange(null);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
setLang(detectLang());
$('dataset').addEventListener('change', e => loadDataset(e.target.value));
fetch('history.csv', { cache: 'no-cache' })
.then(r => {
async function loadDataset(name) {
try {
const r = await fetch(DATA_DIR + encodeURIComponent(name), { cache: 'no-cache' });
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 } };
load(await r.text(), name);
// remember only selections that actually loaded
if (!state.error) try { localStorage.setItem(DATASET_KEY, name); } catch { /* storage unavailable */ }
} catch (err) {
state.error = { key: 'errFetch', params: { file: name, status: err.message } };
render();
});
}
}
// data/ is served as an nginx JSON directory listing: [{ name, type, mtime, size }, …]
async function init() {
setLang(detectLang());
let files = [];
try {
const r = await fetch(DATA_DIR, { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
files = (await r.json())
.filter(f => f.type === 'file' && /\.csv$/i.test(f.name))
.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
state.error = { key: 'errFetch', params: { file: DATA_DIR, status: err.message } };
return render();
}
if (!files.length) {
state.error = { key: 'errNoData' };
return render();
}
state.datasets = files.map(f => f.name);
// last selection if still present, otherwise the most recently modified file
let saved = null;
try { saved = localStorage.getItem(DATASET_KEY); } catch { /* storage unavailable */ }
const newest = files.reduce((a, b) => Date.parse(b.mtime) > Date.parse(a.mtime) ? b : a);
loadDataset(state.datasets.includes(saved) ? saved : newest.name);
}
init();
+10 -4
View File
@@ -33,9 +33,12 @@ const I18N = {
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…”.',
dataset: 'Dataset',
localFile: 'Local file',
errFetch: 'Could not load {file} ({status}) – use “Load CSV…”.',
errNoData: 'No CSV files found in data/ – 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> ' +
'mouse wheel to zoom, double-click or Esc 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.',
@@ -71,9 +74,12 @@ const I18N = {
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.',
dataset: 'Datensatz',
localFile: 'Lokale Datei',
errFetch: '{file} konnte nicht geladen werden ({status}) – bitte „CSV laden…“ verwenden.',
errNoData: 'Keine CSV-Dateien in data/ gefunden – 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, ' +
'zum Verschieben, Mausrad zum Zoomen, Doppelklick oder Esc 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.',
+2 -1
View File
@@ -15,7 +15,8 @@
<header>
<h1 data-i18n="title">Heatpump compressor cycles</h1>
<div class="header-right">
<span class="sub" id="source" data-i18n="loading">loading…</span>
<select id="dataset" hidden aria-label="Dataset"></select>
<span class="sub" id="source"></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>
+2 -2
View File
@@ -26,11 +26,11 @@ h2 {
.sub { color: var(--ink-2); font-size: 13px; }
.error { color: var(--error); }
button, label.file {
button, label.file, select {
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); }
button:hover, label.file:hover, select: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; }