Build container image in CI, bake in datasets, configure auth via env
Build image / build (push) Successful in 3m18s
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:
+70
-14
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user