This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 12:48:09 +01:00
1 parent 439cc20dcf
commit 722a49f6bc
14 files changed
+564 -283

No files matched your search

+224 -42
View File
@@ -5,40 +5,23 @@
var THEME_KEY = 'ts3monolith-theme';
var root = document.documentElement;
var toggle = document.querySelector('.theme-toggle');
var graphs = document.querySelectorAll('.js-live-graph');
var openers = document.querySelectorAll('.js-graph-open');
function currentTheme() {
return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
// RRD graphs are static PNGs rendered server-side in both a dark and a
// light palette (see internal/rrd/rrd.go); swap to the variant matching
// the active theme so panel graphs never clash with the page around them.
function applyGraphTheme(theme) {
var stamp = Date.now();
graphs.forEach(function (img) {
var src = theme === 'light' ? img.dataset.srcLight : img.dataset.src;
if (src) img.src = src + '?v=' + stamp;
});
openers.forEach(function (button) {
var full = theme === 'light' ? button.getAttribute('data-full-light') : button.getAttribute('data-full');
if (full) button.setAttribute('data-full-active', full);
});
}
function applyTheme(mode) {
if (mode === 'light') root.setAttribute('data-theme', 'light');
else root.removeAttribute('data-theme');
applyGraphTheme(mode === 'light' ? 'light' : 'dark');
// Charts read their colors straight from the CSS custom properties at
// draw time, so a theme switch just needs a redraw of the data already
// in hand -- no re-fetch, unlike the old per-theme PNG swap.
renderAllCharts();
}
try {
var saved = window.localStorage.getItem(THEME_KEY);
if (saved) applyTheme(saved);
else applyGraphTheme(currentTheme());
} catch (e) {
applyGraphTheme(currentTheme());
}
} catch (e) { /* ignore */ }
if (toggle) {
toggle.addEventListener('click', function () {
@@ -48,7 +31,200 @@
});
}
// --- live stats + graph refresh -----------------------------------------
// --- RRD charts -----------------------------------------------------------
// Each panel's time series comes from GET /stats/graph/{range} as plain
// JSON (see rrd.Manager.Fetch / GraphData in internal/rrd/rrd.go) and is
// drawn here in a <canvas>, instead of asking the server to render a PNG
// with rrdtool. That makes the chart a normal responsive element (it
// resizes with its card, including on mobile, instead of being locked to
// whatever pixel size rrdtool was told to render) and lets a theme toggle
// or window resize just redraw the cached points rather than re-fetching
// an image.
var chartPanels = document.querySelectorAll('.js-chart');
var chartData = {};
function paletteColor(name, fallback) {
var v = getComputedStyle(root).getPropertyValue(name);
return v ? v.trim() : fallback;
}
function timeFormatter(key) {
if (key === 'week' || key === 'month') {
return function (t) {
return new Date(t * 1000).toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' });
};
}
if (key === 'year') {
return function (t) {
return new Date(t * 1000).toLocaleDateString('pl-PL', { month: 'short', year: 'numeric' });
};
}
return function (t) {
return new Date(t * 1000).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
};
}
function fmtNum(v) {
return v === null || v === undefined ? '—' : String(Math.round(v));
}
function statsText(data) {
return 'Aktualnie: ' + fmtNum(data.current) + ' Średnio: ' + fmtNum(data.average) + ' Maks.: ' + fmtNum(data.max);
}
// Draws one series into canvas, sized to the canvas's current CSS box
// (so the caller controls size purely through layout/CSS). Returns false
// when there is nothing to draw (e.g. a fresh RRD with no samples yet),
// so the caller can show a text fallback instead of a blank box.
function drawChart(canvas, data) {
var points = (data && data.points) || [];
var hasData = points.some(function (p) { return p.v !== null && p.v !== undefined; });
if (!hasData) return false;
var cssW = canvas.clientWidth;
var cssH = canvas.clientHeight;
if (!cssW || !cssH) return false;
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
var ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
var lineColor = paletteColor('--line', '#313832');
var textColor = paletteColor('--muted', '#9da79f');
var accent = paletteColor('--accent', '#8fb9a4');
var padL = 28, padR = 6, padT = 8, padB = 16;
var plotW = Math.max(1, cssW - padL - padR);
var plotH = Math.max(1, cssH - padT - padB);
var values = [];
points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
var maxV = values.length ? Math.max.apply(null, values) : 1;
var top = Math.max(1, Math.ceil(maxV * 1.2));
function xAt(i) {
return padL + (points.length > 1 ? (i / (points.length - 1)) * plotW : plotW / 2);
}
function yAt(v) {
return padT + plotH - (v / top) * plotH;
}
// horizontal grid + y labels (0 / mid / top)
ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
ctx.lineWidth = 1;
[0, top / 2, top].forEach(function (t) {
var y = Math.round(yAt(t)) + 0.5;
ctx.strokeStyle = lineColor;
ctx.globalAlpha = t === 0 ? 0.7 : 0.35;
ctx.beginPath();
ctx.moveTo(padL, y);
ctx.lineTo(padL + plotW, y);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = textColor;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(String(Math.round(t)), padL - 6, yAt(t));
});
// x labels: first / middle / last sample
if (points.length > 1) {
var fmt = timeFormatter(data.key);
ctx.fillStyle = textColor;
ctx.textBaseline = 'alphabetic';
[0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx) {
ctx.textAlign = idx === 0 ? 'left' : (idx === 2 ? 'right' : 'center');
ctx.fillText(fmt(points[i].t), xAt(i), cssH - 3);
});
}
// area + line, broken into separate segments across data gaps (nulls)
// instead of interpolating through them
var seg = [];
function flushSegment() {
if (seg.length < 2) {
seg = [];
return;
}
ctx.beginPath();
ctx.moveTo(seg[0][0], padT + plotH);
seg.forEach(function (pt) { ctx.lineTo(pt[0], pt[1]); });
ctx.lineTo(seg[seg.length - 1][0], padT + plotH);
ctx.closePath();
ctx.fillStyle = accent + '38';
ctx.fill();
ctx.beginPath();
seg.forEach(function (pt, idx) {
if (idx === 0) ctx.moveTo(pt[0], pt[1]);
else ctx.lineTo(pt[0], pt[1]);
});
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.lineWidth = 1.6;
ctx.strokeStyle = accent;
ctx.stroke();
seg = [];
}
points.forEach(function (p, i) {
if (p.v === null || p.v === undefined) {
flushSegment();
return;
}
seg.push([xAt(i), yAt(p.v)]);
});
flushSegment();
return true;
}
function renderPanel(panel) {
var canvas = panel.querySelector('.js-chart-canvas');
var empty = panel.querySelector('.graph-empty');
var statsEl = panel.querySelector('.js-graph-stats');
if (!canvas) return;
var data = chartData[panel.dataset.range];
var ok = !!data && drawChart(canvas, data);
canvas.hidden = !ok;
if (empty) {
empty.hidden = ok;
empty.textContent = data ? 'Brak danych' : 'Wykres niedostępny';
}
if (statsEl) statsEl.textContent = ok ? statsText(data) : '';
}
function renderAllCharts() {
chartPanels.forEach(renderPanel);
}
function loadCharts() {
return Promise.all(Array.prototype.map.call(chartPanels, function (panel) {
var range = panel.dataset.range;
return fetch('/stats/graph/' + range, { cache: 'no-store' })
.then(function (r) {
if (!r.ok) throw new Error('graph');
return r.json();
})
.then(function (data) { chartData[range] = data; })
.catch(function () { chartData[range] = null; });
})).then(renderAllCharts);
}
function debounce(fn, wait) {
var t;
return function () {
clearTimeout(t);
t = setTimeout(fn, wait);
};
}
if (chartPanels.length) {
loadCharts();
window.addEventListener('resize', debounce(renderAllCharts, 150));
}
// --- live stats + chart refresh --------------------------------------------
var count = document.getElementById('online-count');
var status = document.getElementById('refresh-status');
var progress = document.getElementById('route-progress');
@@ -61,57 +237,63 @@
setBusy(true);
if (status) status.textContent = 'Odświeżanie…';
var done = Promise.resolve();
var tasks = [];
if (count) {
done = fetch('/stats/live', { cache: 'no-store' })
tasks.push(fetch('/stats/live', { cache: 'no-store' })
.then(function (response) {
if (!response.ok) throw new Error('stats');
return response.json();
})
.then(function (data) {
if (typeof data.current === 'number') count.textContent = String(data.current);
if (status) status.textContent = 'Dane aktualne';
})
.catch(function () {
if (status) status.textContent = 'Nie udało się odświeżyć';
});
}));
}
if (chartPanels.length) tasks.push(loadCharts());
applyGraphTheme(currentTheme());
done.then(function () { setBusy(false); });
Promise.all(tasks)
.then(function () { if (status) status.textContent = 'Dane aktualne'; })
.catch(function () { if (status) status.textContent = 'Nie udało się odświeżyć'; })
.then(function () { setBusy(false); });
}
if (count || graphs.length) {
if (count || chartPanels.length) {
window.setInterval(refresh, 60000);
}
// --- graph lightbox -------------------------------------------------------
var lightbox = document.getElementById('graph-lightbox');
var lightboxImage = document.getElementById('lightbox-image');
var lightboxCanvas = document.getElementById('lightbox-canvas');
var lightboxTitle = document.getElementById('lightbox-title');
var lightboxStats = document.getElementById('lightbox-stats');
var openers = document.querySelectorAll('.js-graph-open');
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
function openLightbox(src) {
if (!lightbox || !lightboxImage) return;
lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now();
function openLightbox(range) {
if (!lightbox || !lightboxCanvas) return;
var data = chartData[range];
lightbox.hidden = false;
lightbox.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open');
if (lightboxTitle) lightboxTitle.textContent = data ? data.title : '';
if (lightboxStats) lightboxStats.textContent = data ? statsText(data) : '';
// Wait a frame so the now-visible dialog has its real layout size
// before measuring clientWidth/clientHeight for the bigger redraw.
requestAnimationFrame(function () {
if (data) drawChart(lightboxCanvas, data);
});
}
function closeLightbox() {
if (!lightbox || !lightboxImage) return;
if (!lightbox) return;
lightbox.hidden = true;
lightbox.setAttribute('aria-hidden', 'true');
lightboxImage.src = '';
document.body.classList.remove('lightbox-open');
}
openers.forEach(function (button) {
button.addEventListener('click', function () {
var src = button.getAttribute('data-full-active') || button.getAttribute('data-full');
if (src) openLightbox(src);
var panel = button.closest('.js-chart');
if (panel) openLightbox(panel.dataset.range);
});
});