Initial commit: stats-gateway gRPC service + dashboard + CLI
This commit is contained in:
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Renders stats-site (Hugo project) without requiring Hugo, deploys atomically
|
||||
# to /var/www/stats-site/. The Hugo project uses only two template variables;
|
||||
# we substitute them with sed.
|
||||
#
|
||||
# Usage: build.sh [--src DIR] [--dest DIR]
|
||||
# --src default /opt/stats-site
|
||||
# --dest default /var/www/stats-site
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${SRC:-/opt/stats-site}"
|
||||
DEST="${DEST:-/var/www/stats-site}"
|
||||
|
||||
# Hugo variables (mirror config.toml).
|
||||
SITE_TITLE='PSP Speedtest Stats'
|
||||
SITE_DESC='Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут.'
|
||||
|
||||
[ -d "$SRC" ] || { echo "src $SRC not found" >&2; exit 1; }
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap "rm -rf $TMP" EXIT
|
||||
|
||||
# 1. Copy static/ verbatim
|
||||
cp -r "$SRC/static/." "$TMP/"
|
||||
|
||||
# 2. Render layouts/index.html with sed-substitution
|
||||
sed \
|
||||
-e "s|{{ \.Site\.Title }}|${SITE_TITLE}|g" \
|
||||
-e "s|{{ \.Site\.Params\.description }}|${SITE_DESC}|g" \
|
||||
"$SRC/layouts/index.html" > "$TMP/index.html"
|
||||
|
||||
# 3. Atomic rsync, preserving runtime data (snapshot.json) and ownership.
|
||||
# --delete removes files in DEST that aren't in TMP (cleans old assets),
|
||||
# --exclude protects data/ (snapshot generator owns that).
|
||||
rsync -a --delete \
|
||||
--exclude='data/snapshot.json' \
|
||||
--exclude='data/snapshot.json.tmp' \
|
||||
--chown=deploy:www-data \
|
||||
"$TMP/" "$DEST/"
|
||||
|
||||
echo "site built: $(find $DEST -type f | wc -l) files in $DEST"
|
||||
@@ -0,0 +1,10 @@
|
||||
baseURL = "https://stats.projectshitpost.fun/"
|
||||
title = "PSP Speedtest Stats"
|
||||
languageCode = "ru"
|
||||
defaultContentLanguage = "ru"
|
||||
|
||||
[params]
|
||||
description = "Метрики латентности и времени отклика двух geo-распределённых OpenSpeedTest нод. Snapshot обновляется каждые 10 минут."
|
||||
|
||||
[markup.goldmark.renderer]
|
||||
unsafe = true
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: "PSP Speedtest Stats"
|
||||
---
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Site.Title }} — Доступность FL и PL1</title>
|
||||
<meta name="description" content="{{ .Site.Params.description }}">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ .Site.Title }}</h1>
|
||||
<p class="subtitle">Network probe stats — обновляется каждые 10 минут.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="nodes">
|
||||
<article class="node" id="node-fl">
|
||||
<h2>FL — Финляндия</h2>
|
||||
<div class="metrics" data-node="fl">
|
||||
<p class="loading">Загрузка…</p>
|
||||
</div>
|
||||
<canvas class="chart" data-node="fl" width="600" height="180"></canvas>
|
||||
</article>
|
||||
|
||||
<article class="node" id="node-pl1">
|
||||
<h2>PL1 — Польша</h2>
|
||||
<div class="metrics" data-node="pl1">
|
||||
<p class="loading">Загрузка…</p>
|
||||
</div>
|
||||
<canvas class="chart" data-node="pl1" width="600" height="180"></canvas>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="meta">
|
||||
<p id="generated-at">Snapshot age: <span data-bind="age">—</span></p>
|
||||
<p>Probes: HTTP HEAD, TCP RTT, DNS resolve, TLS handshake.</p>
|
||||
<p>Источник: <a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a> (open-source).</p>
|
||||
</section>
|
||||
|
||||
<nav class="footer-nav">
|
||||
<a href="/about/">About</a>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<script src="/js/charts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>About — PSP Speedtest Stats</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>About</h1>
|
||||
</header>
|
||||
<main>
|
||||
<p>Этот dashboard показывает результаты регулярных HTTP-проб двух
|
||||
speedtest-нод (FL — Финляндия, PL1 — Польша) от gitea-CI/CD-узла,
|
||||
раз в 5 минут. Данные хранятся в SQLite на gitea-сервере и
|
||||
публикуются как snapshot.json раз в 10 минут.</p>
|
||||
|
||||
<p>Цель — мониторить деградации latency/throughput для собственного
|
||||
использования. API сервиса описан в
|
||||
<a href="https://gitea.projectshitpost.fun/bergabruh/stats-gateway">stats-gateway</a>.</p>
|
||||
|
||||
<p><a href="/">← Назад</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--fg: #1a1a1a;
|
||||
--bg: #fafafa;
|
||||
--muted: #555;
|
||||
--border: #ddd;
|
||||
--accent: #2563eb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #e8e8e8;
|
||||
--bg: #111;
|
||||
--muted: #aaa;
|
||||
--border: #333;
|
||||
--accent: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 2em auto;
|
||||
padding: 0 1em;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nodes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5em;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.nodes { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.node {
|
||||
padding: 1em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg) 92%, var(--fg) 8%);
|
||||
}
|
||||
|
||||
.node h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.metrics .loading { color: var(--muted); font-style: italic; }
|
||||
|
||||
.metrics .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25em 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.metrics .row:last-child { border-bottom: none; }
|
||||
|
||||
.metrics .label { color: var(--muted); }
|
||||
|
||||
.metrics .value { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.footer-nav a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.footer-nav a:hover { text-decoration: underline; }
|
||||
@@ -0,0 +1 @@
|
||||
# Snapshot files deployed at runtime by stats-snapshot.timer
|
||||
@@ -0,0 +1,137 @@
|
||||
(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);
|
||||
})();
|
||||
Reference in New Issue
Block a user