351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// --- theme (dark/light), persisted per browser -------------------------
|
|
var THEME_KEY = 'ts3monolith-theme';
|
|
var root = document.documentElement;
|
|
var toggle = document.querySelector('.theme-toggle');
|
|
|
|
function currentTheme() {
|
|
return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
}
|
|
|
|
function applyTheme(mode) {
|
|
if (mode === 'light') root.setAttribute('data-theme', 'light');
|
|
else root.removeAttribute('data-theme');
|
|
// 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);
|
|
} catch (e) { /* ignore */ }
|
|
|
|
if (toggle) {
|
|
toggle.addEventListener('click', function () {
|
|
var next = currentTheme() === 'light' ? 'dark' : 'light';
|
|
applyTheme(next);
|
|
try { window.localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
// --- 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 peakPoints = (data && data.peak_points) || [];
|
|
var values = [];
|
|
points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
|
|
peakPoints.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.15));
|
|
|
|
function xAtFor(series, i) {
|
|
return padL + (series.length > 1 ? (i / (series.length - 1)) * plotW : plotW / 2);
|
|
}
|
|
function xAt(i) {
|
|
return xAtFor(points, i);
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
|
|
// For week/month/year the API also returns a MAX series. Draw it first
|
|
// as a subtle dashed line: the solid/filled line remains the smoothed
|
|
// average trend, while real historical peaks are still visible.
|
|
if (peakPoints.length > 1) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
peakPoints.forEach(function (p, i) {
|
|
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
|
var x = xAtFor(peakPoints, i);
|
|
var y = yAt(v);
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineCap = 'round';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = accent;
|
|
ctx.globalAlpha = 0.42;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// Main average/trend line; missing samples are rendered as zero so the
|
|
// graph falls cleanly to the baseline instead of breaking into segments.
|
|
// Short windows represent a discrete user count, so draw them as steps:
|
|
// the value stays constant until the next sample instead of producing
|
|
// narrow triangular needles between 0 and 1/2 users.
|
|
var useSteps = data.key === 'hour' || data.key === 'day';
|
|
var seg = [];
|
|
|
|
function traceSeries(segment) {
|
|
ctx.moveTo(segment[0][0], segment[0][1]);
|
|
for (var i = 1; i < segment.length; i++) {
|
|
if (useSteps) {
|
|
ctx.lineTo(segment[i][0], segment[i - 1][1]);
|
|
}
|
|
ctx.lineTo(segment[i][0], segment[i][1]);
|
|
}
|
|
}
|
|
|
|
function flushSegment() {
|
|
if (seg.length < 2) {
|
|
seg = [];
|
|
return;
|
|
}
|
|
ctx.beginPath();
|
|
ctx.moveTo(seg[0][0], padT + plotH);
|
|
ctx.lineTo(seg[0][0], seg[0][1]);
|
|
for (var i = 1; i < seg.length; i++) {
|
|
if (useSteps) {
|
|
ctx.lineTo(seg[i][0], seg[i - 1][1]);
|
|
}
|
|
ctx.lineTo(seg[i][0], seg[i][1]);
|
|
}
|
|
ctx.lineTo(seg[seg.length - 1][0], padT + plotH);
|
|
ctx.closePath();
|
|
ctx.fillStyle = accent + '38';
|
|
ctx.fill();
|
|
|
|
ctx.beginPath();
|
|
traceSeries(seg);
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineCap = 'round';
|
|
ctx.lineWidth = 1.6;
|
|
ctx.strokeStyle = accent;
|
|
ctx.stroke();
|
|
seg = [];
|
|
}
|
|
points.forEach(function (p, i) {
|
|
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
|
seg.push([xAt(i), yAt(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');
|
|
|
|
function setBusy(busy) {
|
|
if (progress) progress.hidden = !busy;
|
|
}
|
|
|
|
function refresh() {
|
|
setBusy(true);
|
|
if (status) status.textContent = 'Odświeżanie…';
|
|
|
|
var tasks = [];
|
|
if (count) {
|
|
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 (chartPanels.length) tasks.push(loadCharts());
|
|
|
|
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 || chartPanels.length) {
|
|
window.setInterval(refresh, 60000);
|
|
}
|
|
|
|
// --- graph lightbox -------------------------------------------------------
|
|
var lightbox = document.getElementById('graph-lightbox');
|
|
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(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) return;
|
|
lightbox.hidden = true;
|
|
lightbox.setAttribute('aria-hidden', 'true');
|
|
document.body.classList.remove('lightbox-open');
|
|
}
|
|
|
|
openers.forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
var panel = button.closest('.js-chart');
|
|
if (panel) openLightbox(panel.dataset.range);
|
|
});
|
|
});
|
|
|
|
closeButtons.forEach(function (button) {
|
|
button.addEventListener('click', closeLightbox);
|
|
});
|
|
|
|
document.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox();
|
|
});
|
|
})();
|