(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 '