v0.4.1
This commit is contained in:
1 parent
439cc20dcf
commit
722a49f6bc
14 files changed
+564
-283
No files matched your search
+23
-26
@@ -61,28 +61,18 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
||||
|
||||
/* === dashboard shell (stats page) ======================================= */
|
||||
.shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)}
|
||||
.topbar{position:relative;display:grid;grid-template-columns:240px 1fr auto;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px}
|
||||
.brand svg{color:var(--accent);width:24px;height:24px}
|
||||
.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px}
|
||||
.topbar{position:relative;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px;min-width:0}
|
||||
.brand svg{color:var(--accent);width:24px;height:24px;flex:0 0 auto}
|
||||
.brand span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;justify-content:center}
|
||||
.header-online strong{color:var(--text);font-size:16px}
|
||||
.top-actions{display:flex;align-items:center;gap:8px}
|
||||
.top-actions{display:flex;align-items:center;gap:8px;justify-content:flex-end}
|
||||
.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;background:transparent}
|
||||
.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1.1s ease-in-out infinite}
|
||||
.route-progress[hidden]{display:none}
|
||||
@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
|
||||
|
||||
.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}
|
||||
.sidebar{min-height:0;padding:14px 10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto}
|
||||
.nav{display:grid;gap:3px}
|
||||
.nav a{display:flex;align-items:center;gap:10px;height:38px;padding:0 11px;border:1px solid transparent;border-radius:var(--r);color:var(--muted)}
|
||||
.nav a:hover{background:var(--hover);color:var(--text)}
|
||||
.nav a.active{background:var(--selected);color:var(--text);border-color:var(--line)}
|
||||
.nav a svg{width:17px;height:17px}
|
||||
.nav-section{margin:16px 11px 6px;color:var(--subtle);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}
|
||||
.nav-server{display:flex;align-items:center;gap:9px;height:30px;padding:0 11px;color:var(--muted);font-size:12px}
|
||||
.nav-server svg{width:15px;height:15px;flex:0 0 auto}
|
||||
.nav-server span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.icon-sprite{display:none}
|
||||
|
||||
.content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px}
|
||||
@@ -102,18 +92,20 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
||||
.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}
|
||||
.panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;min-height:44px;padding:0 13px;border-bottom:1px solid var(--line)}
|
||||
.panel-head h2{margin:0;font-size:14px;font-weight:650}
|
||||
.panel-head p{margin:0;font-size:11px;color:var(--muted)}
|
||||
|
||||
.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in;overflow:hidden}
|
||||
.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:1200/320;object-fit:contain;transition:transform .18s ease,opacity .18s ease}
|
||||
.graph-image-button:hover img{transform:scale(1.012);opacity:.96}
|
||||
.graph-chart{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in}
|
||||
.graph-chart canvas{display:block;width:100%;height:auto;aspect-ratio:3/1}
|
||||
.graph-empty{margin:0;padding:58px 10px;text-align:center;font-size:12px}
|
||||
.graph-stats{margin:0;padding:9px 13px 12px;font-size:11px;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
.lightbox[hidden]{display:none}
|
||||
.lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px}
|
||||
.lightbox-backdrop{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.78);cursor:zoom-out}
|
||||
.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1500px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 16px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto}
|
||||
.lightbox-dialog img{display:block;width:100%;height:auto}
|
||||
.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1200px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 18px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto}
|
||||
.lightbox-title{margin:0 0 10px;padding-right:90px;font-size:15px;font-weight:650}
|
||||
.lightbox-canvas{display:block;width:100%;height:min(62vh,560px)}
|
||||
.lightbox-dialog .graph-stats{padding:10px 2px 0}
|
||||
.lightbox-close{position:absolute;top:10px;right:12px;display:inline-flex;gap:6px;align-items:center}
|
||||
.lightbox-open{overflow:hidden}
|
||||
|
||||
@@ -158,12 +150,17 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
||||
@media(max-width:1060px){
|
||||
.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}
|
||||
.graphs{grid-template-columns:1fr}
|
||||
.graph-image-button img{aspect-ratio:auto;min-height:260px}
|
||||
.graph-chart canvas{aspect-ratio:auto;height:200px}
|
||||
}
|
||||
@media(max-width:860px){
|
||||
.topbar{grid-template-columns:auto 1fr auto}
|
||||
.body{grid-template-columns:1fr}
|
||||
.sidebar{display:none}
|
||||
.topbar{grid-template-columns:minmax(64px,auto) 1fr auto}
|
||||
#refresh-status{display:none}
|
||||
}
|
||||
@media(max-width:520px){
|
||||
.brand span{max-width:40vw}
|
||||
.header-online .badge{display:none}
|
||||
.btn-label{display:none}
|
||||
.top-actions .btn{padding:7px 10px}
|
||||
}
|
||||
@media(max-width:620px){
|
||||
.cards{grid-template-columns:1fr}
|
||||
|
||||
+224
-42
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+12
-31
@@ -5,13 +5,13 @@ import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -111,7 +111,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
|
||||
mux.HandleFunc("GET /stats", s.stats)
|
||||
mux.HandleFunc("GET /stats/live", s.statsLive)
|
||||
mux.HandleFunc("GET /graph/{file}", s.graph)
|
||||
mux.HandleFunc("GET /stats/graph/{key}", s.graphData)
|
||||
mux.HandleFunc("GET /channel", s.channel)
|
||||
mux.HandleFunc("GET /embed/channel", s.channel)
|
||||
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
||||
@@ -250,43 +250,26 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) graphData(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r).String()
|
||||
// Graph PNGs are cheap to serve: rrd.Manager.Graph caches each
|
||||
// key+theme render on disk for ~55s, so most hits here are a plain
|
||||
// file read, not an rrdtool invocation. One page view alone can
|
||||
// legitimately touch this 6 ranges x 2 themes (lightbox open, theme
|
||||
// toggle) times, before counting multiple tabs or several visitors
|
||||
// sharing one IP (same household/NAT) -- see README "Rate limiting"
|
||||
// for tuning RATE_LIMIT_GRAPH if this still trips in practice.
|
||||
// Each hit here is one rrdtool fetch returning a few hundred small
|
||||
// JSON numbers read straight from the local RRD file -- cheap, so
|
||||
// the limit mainly exists to stop outright abuse. See README "Rate
|
||||
// limiting" for tuning RATE_LIMIT_GRAPH.
|
||||
if s.cfg.RateLimitEnabled && !s.limit.allow("graph|"+ip, s.cfg.RateLimitGraph, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
file := r.PathValue("file")
|
||||
if !strings.HasSuffix(file, ".png") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSuffix(file, ".png")
|
||||
theme := "dark"
|
||||
if strings.HasSuffix(key, "-light") {
|
||||
theme = "light"
|
||||
key = strings.TrimSuffix(key, "-light")
|
||||
}
|
||||
if key == "" || strings.Contains(key, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p, err := s.rrd.Graph(ctx, key, theme)
|
||||
data, err := s.rrd.Fetch(ctx, r.PathValue("key"))
|
||||
if err != nil {
|
||||
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
|
||||
http.Error(w, "graph data unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
http.ServeFile(w, r, p)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -579,5 +562,3 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }
|
||||
@@ -11,9 +11,9 @@
|
||||
{{template "icons" .}}
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/stats">
|
||||
<a class="brand" href="/stats" title="{{if .InfoAvailable}}{{.ServerName}}{{end}}">
|
||||
<svg class="icon" width="24" height="24"><use href="#icon-headset"/></svg>
|
||||
<span>Panel serwera</span>
|
||||
<span>{{if .InfoAvailable}}{{.ServerName}}{{else}}Serwer TS3{{end}}</span>
|
||||
</a>
|
||||
<div class="header-online" aria-live="polite">
|
||||
<span class="badge ok"><span class="dot"></span>Online</span>
|
||||
@@ -21,8 +21,8 @@
|
||||
<span class="muted" id="refresh-status">Dane aktualne</span>
|
||||
</div>
|
||||
<nav class="top-actions" aria-label="Nawigacja">
|
||||
<a class="btn ghost" href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg>Połącz</a>
|
||||
<a class="btn primary" href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg>Utwórz kanał</a>
|
||||
<a class="btn ghost" href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg><span class="btn-label">Połącz</span></a>
|
||||
<a class="btn primary" href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg><span class="btn-label">Utwórz kanał</span></a>
|
||||
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
||||
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
||||
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
||||
@@ -31,72 +31,59 @@
|
||||
<div class="route-progress" id="route-progress" hidden><span></span></div>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<aside class="sidebar">
|
||||
<nav class="nav">
|
||||
<a class="active" href="/stats"><svg class="icon"><use href="#icon-activity"/></svg>Statystyki</a>
|
||||
<a href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg>Utwórz kanał</a>
|
||||
<a href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg>Połącz z TS3</a>
|
||||
</nav>
|
||||
<p class="nav-section">Serwer</p>
|
||||
<div class="nav-server" title="{{if .InfoAvailable}}{{.ServerName}}{{end}}">
|
||||
<svg class="icon"><use href="#icon-server"/></svg><span class="mono">{{if .InfoAvailable}}{{.ServerName}}{{else}}—{{end}}</span>
|
||||
<main class="content">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Statystyki serwera</p>
|
||||
<h1>Aktywność TeamSpeak 3</h1>
|
||||
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<main class="content">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Statystyki serwera</p>
|
||||
<h1>Aktywność TeamSpeak 3</h1>
|
||||
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="cards" aria-label="Podsumowanie">
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-users"/></svg>
|
||||
<div><span class="k">Online teraz</span><div class="v">{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}}<span class="vs muted"> / {{.MaxClients}}</span>{{end}}{{end}}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-clock"/></svg>
|
||||
<div><span class="k">Uptime</span><div class="vs">{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-server"/></svg>
|
||||
<div><span class="k">Wersja serwera</span><div class="vs">{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-sliders"/></svg>
|
||||
<div><span class="k">Platforma</span><div class="vs">{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cards" aria-label="Podsumowanie">
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-users"/></svg>
|
||||
<div><span class="k">Online teraz</span><div class="v">{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}}<span class="vs muted"> / {{.MaxClients}}</span>{{end}}{{end}}</div></div>
|
||||
<p class="section-title">Użytkownicy online</p>
|
||||
<section class="graphs" aria-label="Wykresy statystyk">
|
||||
{{range .Ranges}}
|
||||
<article class="panel js-chart" data-range="{{.Key}}">
|
||||
<div class="panel-head">
|
||||
<h2>{{.Title}}</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-clock"/></svg>
|
||||
<div><span class="k">Uptime</span><div class="vs">{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-server"/></svg>
|
||||
<div><span class="k">Wersja serwera</span><div class="vs">{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<svg class="icon"><use href="#icon-sliders"/></svg>
|
||||
<div><span class="k">Platforma</span><div class="vs">{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="section-title">Wykresy obecności</p>
|
||||
<section class="graphs" aria-label="Wykresy statystyk">
|
||||
{{range .Ranges}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>{{.Title}}</h2>
|
||||
<p>Liczba użytkowników online</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="graph-image-button js-graph-open" type="button" data-full="/graph/{{.Key}}.png" data-full-light="/graph/{{.Key}}-light.png" aria-label="Powiększ wykres {{.Title}}">
|
||||
<img class="js-live-graph" loading="lazy" src="/graph/{{.Key}}.png" data-src="/graph/{{.Key}}.png" data-src-light="/graph/{{.Key}}-light.png" alt="{{.Title}}">
|
||||
</button>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<button class="graph-chart js-graph-open" type="button" aria-label="Powiększ wykres {{.Title}}">
|
||||
<canvas class="js-chart-canvas" aria-hidden="true"></canvas>
|
||||
<p class="graph-empty muted" hidden>Wczytywanie…</p>
|
||||
</button>
|
||||
<p class="graph-stats muted js-graph-stats"></p>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
|
||||
<button class="lightbox-backdrop" type="button" data-lightbox-close aria-label="Zamknij podgląd"></button>
|
||||
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="Powiększony wykres">
|
||||
<button class="btn ghost lightbox-close" type="button" data-lightbox-close><svg class="icon"><use href="#icon-x"/></svg>Zamknij</button>
|
||||
<img id="lightbox-image" src="" alt="Powiększony wykres">
|
||||
<h2 class="lightbox-title" id="lightbox-title"></h2>
|
||||
<canvas id="lightbox-canvas" class="lightbox-canvas"></canvas>
|
||||
<p class="graph-stats muted" id="lightbox-stats"></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user