SSR module: dark VITEC cockpit with live charts
Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9923e19e0
commit
67ded6e502
6 files changed
+1119
-342
No files matched your search
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* VITEC SSR cockpit (module web_vitecssr).
|
||||
* Draws the gauges, the availability strip, the traffic chart and the
|
||||
* sparkline from the JSON the controller puts on #ssr-cockpit
|
||||
* (SsrController::cockpitData), and keeps the live parts current by
|
||||
* polling the vitec_ssr_health ajax route (GET /__health of the node):
|
||||
* every 2 s while a warm run is active, every 15 s otherwise.
|
||||
* Chart.js is the copy TYPO3 ships with EXT:dashboard.
|
||||
*/
|
||||
import AjaxRequest from '@typo3/core/ajax/ajax-request.js';
|
||||
import { Chart, registerables } from '@typo3/dashboard/contrib/chartjs.js';
|
||||
|
||||
const root = document.getElementById('ssr-cockpit');
|
||||
if (root) {
|
||||
Chart.register(...registerables);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(root.dataset.json || '{}');
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
|
||||
const C = 2 * Math.PI * 40;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const nf = new Intl.NumberFormat('en-GB');
|
||||
const num = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
|
||||
const hhmm = (ts) => new Date(ts * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const css = getComputedStyle(root);
|
||||
const tok = (name) => css.getPropertyValue(name).trim();
|
||||
const col = {
|
||||
navy: tok('--vc-navy-hi'), sky: tok('--vc-sky'), warn: tok('--vc-warn'), bad: tok('--vc-bad'),
|
||||
orange: tok('--vc-orange'), ok: tok('--vc-ok'), faint: tok('--vc-faint'), panel: tok('--vc-panel'),
|
||||
text: tok('--vc-text'), muted: tok('--vc-muted'),
|
||||
};
|
||||
|
||||
const setRing = (el, frac) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.style.strokeDasharray = String(C);
|
||||
el.style.strokeDashoffset = String(C * (1 - Math.max(0, Math.min(1, frac))));
|
||||
};
|
||||
|
||||
/* Hit rate gauge */
|
||||
const hit = data.hitRate;
|
||||
if (typeof hit === 'number') {
|
||||
$('vc-hit').textContent = hit.toFixed(1);
|
||||
requestAnimationFrame(() => setRing($('vc-ring-hit'), hit / 100));
|
||||
if (typeof data.hitRatePrev === 'number') {
|
||||
const d = Math.round((hit - data.hitRatePrev) * 10) / 10;
|
||||
const cls = d >= 0 ? 'is-up' : 'is-down';
|
||||
$('vc-hit-trend').innerHTML = '<span class="' + cls + '">' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
|
||||
+ ' pts</span> vs. the 24 h before';
|
||||
}
|
||||
} else {
|
||||
$('vc-hit-trend').textContent = 'no traffic counters yet';
|
||||
}
|
||||
|
||||
/* Response time */
|
||||
if (data.msMedian !== null && data.msMedian !== undefined) {
|
||||
$('vc-ms-median').textContent = data.msMedian + ' ms';
|
||||
$('vc-ms-p95').textContent = data.msP95 + ' ms';
|
||||
}
|
||||
const drawSpark = () => {
|
||||
const sp = (data.spark || []).map((p) => p[1]);
|
||||
const el = $('vc-spark');
|
||||
if (!el || sp.length < 2) {
|
||||
return;
|
||||
}
|
||||
const w = 300;
|
||||
const h = 46;
|
||||
const max = Math.max(...sp) * 1.08;
|
||||
const min = Math.min(...sp) * 0.85;
|
||||
const span = Math.max(1, max - min);
|
||||
const pts = sp.map((v, i) => [(i / (sp.length - 1)) * w, h - ((v - min) / span) * h]);
|
||||
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||
const last = pts[pts.length - 1];
|
||||
el.innerHTML = '<defs><linearGradient id="vc-sg" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0" stop-color="' + col.orange + '" stop-opacity=".45"/>'
|
||||
+ '<stop offset="1" stop-color="' + col.orange + '" stop-opacity="0"/></linearGradient></defs>'
|
||||
+ '<path d="' + d + ' L' + w + ' ' + h + ' L0 ' + h + 'Z" fill="url(#vc-sg)"/>'
|
||||
+ '<path d="' + d + '" fill="none" stroke="' + col.orange + '" stroke-width="2" vector-effect="non-scaling-stroke"/>'
|
||||
+ '<circle cx="' + last[0].toFixed(1) + '" cy="' + last[1].toFixed(1) + '" r="3.5" fill="' + col.orange
|
||||
+ '" stroke="' + col.panel + '" stroke-width="2"/>';
|
||||
};
|
||||
drawSpark();
|
||||
|
||||
/* Cache fill */
|
||||
const renderCache = (pages, mb) => {
|
||||
const limit = num(data.cacheLimitMb);
|
||||
if (typeof pages === 'number') {
|
||||
$('vc-cache-pages').textContent = nf.format(pages);
|
||||
}
|
||||
if (typeof mb === 'number') {
|
||||
$('vc-cache-mb').textContent = nf.format(mb);
|
||||
if (limit > 0) {
|
||||
setRing($('vc-ring-cache'), mb / limit);
|
||||
$('vc-cache-pct').textContent = ' · ' + Math.round((mb / limit) * 100) + ' % used';
|
||||
}
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => renderCache(data.cachedPages, data.cacheMb));
|
||||
|
||||
/* Client errors per hour */
|
||||
const mini = $('vc-mini');
|
||||
const eh = data.errHours || [];
|
||||
const ehMax = Math.max(1, ...eh);
|
||||
eh.forEach((v, i) => {
|
||||
const b = document.createElement('i');
|
||||
b.style.height = (v ? Math.max(14, (v / ehMax) * 100) : 5) + '%';
|
||||
if (v > 0 && v >= ehMax && v >= 3) {
|
||||
b.className = 'is-hot';
|
||||
}
|
||||
b.title = (23 - i === 0 ? 'this hour' : (23 - i) + ' h ago') + ': ' + v + ' report' + (v === 1 ? '' : 's');
|
||||
mini.appendChild(b);
|
||||
});
|
||||
|
||||
/* Availability strip */
|
||||
const strip = data.strip || { w: [], end: data.now, slot: 900 };
|
||||
const bars = $('vc-bars');
|
||||
const stripStart = strip.end - 96 * strip.slot;
|
||||
(strip.w || []).forEach((w, i) => {
|
||||
const b = document.createElement('i');
|
||||
const t = stripStart + i * strip.slot;
|
||||
let label = 'no sample';
|
||||
if (w === null) {
|
||||
b.className = 'is-none';
|
||||
} else if (w.s === 2) {
|
||||
b.className = 'is-down';
|
||||
label = 'down';
|
||||
} else if (w.s === 1) {
|
||||
b.className = 'is-slow';
|
||||
label = 'slow, ' + w.ms + ' ms';
|
||||
} else {
|
||||
label = 'ok, ' + w.ms + ' ms';
|
||||
b.style.height = Math.round(78 + Math.min(22, (w.ms / 200) * 22)) + '%';
|
||||
}
|
||||
b.title = hhmm(t) + '–' + hhmm(t + strip.slot) + ' · ' + label;
|
||||
bars.appendChild(b);
|
||||
});
|
||||
const axis = $('vc-uptime-axis');
|
||||
[0, 24, 48, 72, 96].forEach((i, k) => {
|
||||
const s = document.createElement('span');
|
||||
s.textContent = k === 4 ? 'now' : hhmm(stripStart + i * strip.slot);
|
||||
axis.appendChild(s);
|
||||
});
|
||||
if (typeof data.uptimePct === 'number') {
|
||||
const slow = num(data.slowWindows);
|
||||
const down = num(data.downWindows);
|
||||
$('vc-uptime-sum').innerHTML = '<b>' + data.uptimePct.toFixed(1) + ' %</b> up · ' + slow + ' slow window'
|
||||
+ (slow === 1 ? '' : 's') + ' · ' + down + ' outage' + (down === 1 ? '' : 's');
|
||||
} else {
|
||||
$('vc-uptime-sum').textContent = 'no samples yet';
|
||||
}
|
||||
|
||||
/* Traffic chart */
|
||||
let chart = null;
|
||||
const label = (ts, size) => {
|
||||
const d = new Date(ts * 1000);
|
||||
const hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
return size >= 14400 ? d.toLocaleDateString('en-GB', { weekday: 'short' }) + ' ' + hm : hm;
|
||||
};
|
||||
const draw = (key) => {
|
||||
const range = (data.range || {})[key];
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const b = range.b;
|
||||
const sum = (k) => b.reduce((a, x) => a + num(x[k]), 0);
|
||||
const h = sum('hit');
|
||||
const st = sum('stale');
|
||||
const m = sum('miss');
|
||||
const total = h + st + m;
|
||||
$('vc-st-req').textContent = total ? nf.format(total + sum('bypass')) : '–';
|
||||
$('vc-st-hit').textContent = total ? (((h + st) / total) * 100).toFixed(1) + ' %' : '–';
|
||||
$('vc-st-err').textContent = nf.format(sum('errors'));
|
||||
$('vc-st-purge').textContent = nf.format(sum('purges'));
|
||||
const hasMs = b.some((x) => x.ms !== null);
|
||||
$('vc-traffic-empty').hidden = total > 0 || hasMs;
|
||||
|
||||
const maxY = Math.max(1, ...b.map((x) => num(x.hit) + num(x.stale) + num(x.miss)));
|
||||
const maxMs = Math.max(100, ...b.map((x) => num(x.ms)));
|
||||
const tick = { color: col.faint, font: { size: 11 } };
|
||||
const cfg = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: b.map((x) => label(x.t, range.size)),
|
||||
datasets: [
|
||||
{ label: 'Served from cache', data: b.map((x) => x.hit), backgroundColor: col.navy, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Stale, refreshed in background', data: b.map((x) => x.stale), backgroundColor: col.sky, stack: 'r', yAxisID: 'y', order: 3 },
|
||||
{ label: 'Rendered fresh', data: b.map((x) => x.miss), backgroundColor: col.warn, stack: 'r', borderRadius: { topLeft: 3, topRight: 3 }, yAxisID: 'y', order: 3 },
|
||||
{
|
||||
type: 'line', label: 'Response time', data: b.map((x) => x.ms), borderColor: col.orange, borderWidth: 2,
|
||||
pointRadius: 0, pointHoverRadius: 4, pointBackgroundColor: col.orange, tension: 0.35, spanGaps: true, yAxisID: 'y1', order: 1,
|
||||
},
|
||||
{
|
||||
type: 'line', label: 'Purge', data: b.map((x) => (x.purges ? maxY * 1.06 : null)), showLine: false,
|
||||
pointStyle: 'triangle', pointRadius: 7, pointHoverRadius: 9, pointBackgroundColor: col.bad, pointBorderColor: col.panel,
|
||||
yAxisID: 'y', order: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 600 },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#0b1131', borderColor: 'rgba(140,156,255,.3)', borderWidth: 1,
|
||||
titleColor: col.text, bodyColor: '#c4cbf0', padding: 10,
|
||||
filter: (it) => it.raw !== null,
|
||||
callbacks: {
|
||||
label: (c) => {
|
||||
if (c.dataset.label === 'Purge') {
|
||||
const x = b[c.dataIndex];
|
||||
return ' ' + x.purges + ' purge' + (x.purges === 1 ? '' : 's') + (x.purgeAll ? ', ' + x.purgeAll + ' of them everything' : '');
|
||||
}
|
||||
if (c.dataset.label === 'Response time') {
|
||||
return ' Response time: ' + c.raw + ' ms';
|
||||
}
|
||||
return ' ' + c.dataset.label + ': ' + nf.format(c.raw);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { ...tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 8 }, border: { color: 'rgba(140,156,255,.2)' } },
|
||||
y: {
|
||||
stacked: true, beginAtZero: true, suggestedMax: maxY * 1.12, grid: { color: 'rgba(140,156,255,.08)' }, ticks: tick,
|
||||
border: { display: false }, title: { display: true, text: 'requests', color: col.faint },
|
||||
},
|
||||
y1: {
|
||||
position: 'right', beginAtZero: true, suggestedMax: Math.ceil(maxMs * 1.3), grid: { display: false },
|
||||
ticks: { ...tick, color: col.orange, callback: (v) => v + ' ms' }, border: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
chart = new Chart($('vc-traffic'), cfg);
|
||||
};
|
||||
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
|
||||
// Draw once the stylesheet has laid out the grid, and follow the box
|
||||
// afterwards: Chart.js alone kept the width it saw on first paint.
|
||||
const box = $('vc-traffic').parentElement;
|
||||
const start = () => {
|
||||
draw('24h');
|
||||
if (window.ResizeObserver) {
|
||||
let size = box.clientWidth + 'x' + box.clientHeight;
|
||||
new ResizeObserver(() => {
|
||||
const now = box.clientWidth + 'x' + box.clientHeight;
|
||||
if (chart && now !== size) {
|
||||
size = now;
|
||||
chart.resize();
|
||||
}
|
||||
}).observe(box);
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
requestAnimationFrame(start);
|
||||
} else {
|
||||
window.addEventListener('load', () => requestAnimationFrame(start), { once: true });
|
||||
}
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
root.querySelectorAll('.vc-seg button[data-range]').forEach((x) => x.setAttribute('aria-pressed', x === btn ? 'true' : 'false'));
|
||||
draw(btn.dataset.range);
|
||||
});
|
||||
});
|
||||
|
||||
/* Warm cache + live status */
|
||||
const warmBtn = $('ssr-warm-button');
|
||||
const warmLabel = $('vc-warm-label');
|
||||
const warmPct = $('vc-warm-pct');
|
||||
const warmMeta = $('vc-warm-meta');
|
||||
const warmPill = $('vc-warm-pill');
|
||||
const warmRing = $('vc-ring-warm');
|
||||
const pill = (cls, text) => {
|
||||
warmPill.hidden = !text;
|
||||
warmPill.className = 'vc-pill ' + cls;
|
||||
warmPill.textContent = text || '';
|
||||
};
|
||||
const renderWarm = (w) => {
|
||||
w = w || {};
|
||||
const queued = num(w.queued);
|
||||
const handled = num(w.done) + num(w.skipped) + num(w.failed);
|
||||
const failed = num(w.failed);
|
||||
const failedHtml = failed ? '<span class="vc-bad-text">' + failed + ' failed</span>' : '0 failed';
|
||||
if (w.running) {
|
||||
const f = queued > 0 ? handled / queued : 0;
|
||||
setRing(warmRing, f);
|
||||
warmPct.textContent = Math.round(f * 100) + ' %';
|
||||
warmMeta.innerHTML = '<b>' + nf.format(handled) + '</b> of ' + nf.format(queued) + ' pages<br>'
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + '<br>live, every 2 seconds';
|
||||
pill('is-sky', 'running');
|
||||
} else if (w.startedAt) {
|
||||
setRing(warmRing, queued > 0 ? handled / queued : 1);
|
||||
warmPct.textContent = failed || w.aborted ? '!' : '✓';
|
||||
const started = new Date(w.startedAt);
|
||||
const secs = w.finishedAt ? Math.round((new Date(w.finishedAt) - started) / 1000) : null;
|
||||
const took = secs === null ? '' : '<br>took ' + (secs >= 120 ? Math.round(secs / 60) + ' min' : secs + ' s');
|
||||
warmMeta.innerHTML = 'Last run ' + started.toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
+ '<br><b>' + nf.format(num(w.done)) + '</b> of ' + nf.format(queued) + ' rendered · '
|
||||
+ num(w.skipped) + ' still fresh · ' + failedHtml + took;
|
||||
pill(w.aborted ? 'is-warn' : (failed ? 'is-bad' : 'is-ok'), w.aborted ? 'aborted by restart' : (failed ? 'with errors' : 'done'));
|
||||
} else {
|
||||
setRing(warmRing, 0);
|
||||
warmPct.textContent = '–';
|
||||
warmMeta.textContent = 'No run since the server started.';
|
||||
pill('', '');
|
||||
}
|
||||
if (warmBtn) {
|
||||
warmBtn.disabled = !!w.running;
|
||||
warmLabel.textContent = w.running ? 'Warming …' : 'Warm cache';
|
||||
}
|
||||
return !!w.running;
|
||||
};
|
||||
requestAnimationFrame(() => renderWarm(data.warming));
|
||||
|
||||
const renderOnline = (online) => {
|
||||
const p = $('vc-pulse');
|
||||
const t = $('vc-live-title');
|
||||
if (!p || !t) {
|
||||
return;
|
||||
}
|
||||
p.classList.toggle('is-down', !online);
|
||||
t.innerHTML = online ? 'SSR <span class="vc-ok-text">online</span>' : 'SSR <span class="vc-bad-text">not reachable</span>';
|
||||
};
|
||||
|
||||
let timer = null;
|
||||
const schedule = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, ms);
|
||||
};
|
||||
const poll = () => {
|
||||
new AjaxRequest(TYPO3.settings.ajaxUrls.vitec_ssr_health).get()
|
||||
.then((response) => response.resolve())
|
||||
.then((res) => {
|
||||
const h = res && res.health;
|
||||
renderOnline(!!h);
|
||||
let running = false;
|
||||
if (h) {
|
||||
running = renderWarm(h.warming);
|
||||
renderCache(h.cachedPages, h.cacheMB);
|
||||
if (typeof h.responseMs === 'number') {
|
||||
$('vc-ms').textContent = h.responseMs;
|
||||
}
|
||||
}
|
||||
schedule(running ? 2000 : 15000);
|
||||
})
|
||||
.catch(() => schedule(15000));
|
||||
};
|
||||
schedule(data.warming && data.warming.running ? 1000 : 15000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
clearTimeout(timer);
|
||||
} else {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user