/** * 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(); } }); }