(function () { 'use strict'; const SNAPSHOT_URL = '/data/snapshot.json'; async function load() { let data; try { const r = await fetch(SNAPSHOT_URL, { cache: 'no-cache' }); if (!r.ok) throw new Error('HTTP ' + r.status); data = await r.json(); } catch (e) { console.error('snapshot load failed', e); document.querySelectorAll('.metrics').forEach(el => { el.innerHTML = '

Snapshot не загружен (' + e.message + ').

'; }); return; } renderAge(data.generated_at); for (const node of ['fl', 'pl1']) { const nd = data.nodes && data.nodes[node]; if (!nd || !nd.recent || nd.recent.length === 0) { renderMetrics(node, null); continue; } const samples = nd.recent; renderMetrics(node, samples); renderChart(node, samples); } } function renderAge(generatedAt) { const el = document.querySelector('[data-bind="age"]'); if (!el || !generatedAt) return; const ts = new Date(generatedAt); const ageS = Math.round((Date.now() - ts.getTime()) / 1000); if (ageS < 90) el.textContent = ageS + 's'; else if (ageS < 5400) el.textContent = Math.round(ageS / 60) + 'min'; else el.textContent = Math.round(ageS / 3600) + 'h'; } function renderMetrics(node, samples) { const block = document.querySelector('.metrics[data-node="' + node + '"]'); if (!block) return; if (!samples || samples.length === 0) { block.innerHTML = '

Нет данных за окно.

'; return; } // most-recent sample const latest = samples[0]; // p50/p95 of http_ms over the window const httpVals = samples.map(s => s.http_ms).filter(v => typeof v === 'number').sort((a, b) => a - b); const p50 = httpVals[Math.floor(httpVals.length * 0.5)] || 0; const p95 = httpVals[Math.floor(httpVals.length * 0.95)] || 0; block.innerHTML = [ row('Last HTTP', fmtMs(latest.http_ms)), row('Last TLS handshake', fmtMs(latest.tls_ms)), row('p50 HTTP (24h)', fmtMs(p50)), row('p95 HTTP (24h)', fmtMs(p95)), row('Samples (24h)', String(samples.length)), ].join(''); } function row(label, value) { return '
' + label + '' + value + '
'; } function fmtMs(v) { if (typeof v !== 'number') return '—'; if (v < 10) return v.toFixed(2) + ' ms'; if (v < 100) return v.toFixed(1) + ' ms'; return Math.round(v) + ' ms'; } function renderChart(node, samples) { const canvas = document.querySelector('.chart[data-node="' + node + '"]'); if (!canvas || !canvas.getContext) return; const ctx = canvas.getContext('2d'); // High-DPI scaling const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth || canvas.width; const cssH = canvas.height; canvas.width = cssW * dpr; canvas.height = cssH * dpr; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, cssW, cssH); const points = samples .map(s => ({ t: new Date(s.ts).getTime(), v: s.http_ms })) .filter(p => !isNaN(p.t) && typeof p.v === 'number') .sort((a, b) => a.t - b.t); if (points.length === 0) return; const tMin = points[0].t; const tMax = points[points.length - 1].t; const tSpan = Math.max(1, tMax - tMin); const vMax = Math.max(...points.map(p => p.v)) * 1.1 || 1; // axes const pad = 24; const w = cssW - pad * 2; const h = cssH - pad * 2; const muted = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#555'; const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#2563eb'; ctx.strokeStyle = muted; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, pad + h); ctx.lineTo(pad + w, pad + h); ctx.stroke(); // y-axis label (max value) ctx.fillStyle = muted; ctx.font = '10px system-ui, sans-serif'; ctx.textAlign = 'right'; ctx.fillText(Math.round(vMax) + ' ms', pad - 2, pad + 8); ctx.fillText('0', pad - 2, pad + h); // points ctx.fillStyle = accent; for (const p of points) { const x = pad + ((p.t - tMin) / tSpan) * w; const y = pad + h - (p.v / vMax) * h; ctx.beginPath(); ctx.arc(x, y, 1.5, 0, Math.PI * 2); ctx.fill(); } } document.addEventListener('DOMContentLoaded', load); })();