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();