Files
VITEC-website/packages/vitec/Resources/Public/Javascript/ssr-cockpit.js
T
orascheandClaude Opus 5.5 67ded6e502 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>
2026-11-11 10:52:46 +01:00

367 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
});
}