/**
* 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 = '' + (d >= 0 ? '▲ ' : '▼ ') + Math.abs(d).toFixed(1)
+ ' pts 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 = ''
+ ''
+ ''
+ ''
+ ''
+ '';
};
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 = '' + data.uptimePct.toFixed(1) + ' % 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 ? '' + failed + ' failed' : '0 failed';
if (w.running) {
const f = queued > 0 ? handled / queued : 0;
setRing(warmRing, f);
warmPct.textContent = Math.round(f * 100) + ' %';
warmMeta.innerHTML = '' + nf.format(handled) + ' of ' + nf.format(queued) + ' pages
'
+ num(w.skipped) + ' still fresh · ' + failedHtml + '
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 ? '' : '
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' })
+ '
' + nf.format(num(w.done)) + ' 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 online' : 'SSR not reachable';
};
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();
}
});
}