Files
stats-gateway/stats-site/static/js/charts.js
T

138 lines
4.4 KiB
JavaScript

(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 = '<p class="loading">Snapshot не загружен (' + e.message + ').</p>';
});
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 = '<p class="loading">Нет данных за окно.</p>';
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 '<div class="row"><span class="label">' + label + '</span><span class="value">' + value + '</span></div>';
}
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);
})();