Files
420moni/monitor.js
2026-07-07 18:09:52 +02:00

364 lines
16 KiB
JavaScript

import Database from 'better-sqlite3';
import chalk from 'chalk';
import express from 'express';
import react from '@vitejs/plugin-react';
import { createServer as createHttpServer } from 'node:http';
import { createServer as createViteServer } from 'vite';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
// ── Config ────────────────────────────────────────────────────────────────────
const __dirname = dirname(fileURLToPath(import.meta.url));
const TSE_URL = 'http://3.78.227.48:20001/';
const POLL_MS = 60_000; // poll every 60 s
const DB_PATH = './tse_events.db';
const PORT = 20080;
const POLL_S = 60; // expected interval (gap detection)
const GAP_S = POLL_S * 2; // gap > 120 s → outage
const AGG_INTERVAL = 2 * 60_000; // re-aggregate every 2 min
const AGG_LOOKBACK_H = 3; // hours covered on incremental run
// ── DB ────────────────────────────────────────────────────────────────────────
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
status TEXT,
service TEXT,
version TEXT,
serial_number TEXT,
signature_counter INTEGER,
transaction_counter INTEGER,
registered_clients INTEGER,
initialized INTEGER,
created_at TEXT,
fcc_version TEXT,
db_path TEXT,
delta INTEGER,
response_ms INTEGER
);
CREATE TABLE IF NOT EXISTS hourly_stats (
hour TEXT PRIMARY KEY,
total_polls INTEGER NOT NULL DEFAULT 0,
avg_ms REAL,
min_ms INTEGER,
max_ms INTEGER,
outage_s INTEGER NOT NULL DEFAULT 0,
uptime_pct REAL
);
CREATE TABLE IF NOT EXISTS daily_stats (
date TEXT PRIMARY KEY,
total_polls INTEGER NOT NULL DEFAULT 0,
avg_ms REAL,
min_ms INTEGER,
max_ms INTEGER,
outage_s INTEGER NOT NULL DEFAULT 0,
uptime_pct REAL
);
CREATE TABLE IF NOT EXISTS outages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL UNIQUE,
recovered_at TEXT,
duration_s INTEGER
);
`);
// migrate old DBs missing the column
try { db.exec('ALTER TABLE events ADD COLUMN response_ms INTEGER'); } catch {}
try { db.exec('ALTER TABLE events ADD COLUMN delta INTEGER'); } catch {}
// ── Prepared statements ───────────────────────────────────────────────────────
const insertEvent = db.prepare(`
INSERT INTO events
(ts, status, service, version, serial_number,
signature_counter, transaction_counter, registered_clients,
initialized, created_at, fcc_version, db_path, delta, response_ms)
VALUES
(@ts, @status, @service, @version, @serialNumber,
@signatureCounter, @transactionCounter, @registeredClients,
@initialized, @createdAt, @fccVersion, @dbPath, @delta, @responseMs)
`);
const lastRow = db.prepare('SELECT signature_counter FROM events ORDER BY id DESC LIMIT 1');
const stmts = {
eventsInRange: db.prepare(
'SELECT ts, response_ms FROM events WHERE ts >= ? AND ts < ? ORDER BY ts'
),
gapsFrom: db.prepare(`
WITH ordered AS (
SELECT ts, LAG(ts) OVER (ORDER BY ts) AS prev_ts
FROM events WHERE ts >= ?
)
SELECT
strftime('%Y-%m-%dT%H:%M:%SZ', prev_ts, '+' || ? || ' seconds') AS started_at,
ts AS recovered_at,
CAST((julianday(ts) - julianday(prev_ts)) * 86400 AS INTEGER) AS gap_s
FROM ordered
WHERE prev_ts IS NOT NULL
AND CAST((julianday(ts) - julianday(prev_ts)) * 86400 AS INTEGER) > ?
`),
upsertOutage: db.prepare(`
INSERT INTO outages (started_at, recovered_at, duration_s)
VALUES (@started_at, @recovered_at, @duration_s)
ON CONFLICT(started_at) DO UPDATE SET
recovered_at = excluded.recovered_at,
duration_s = excluded.duration_s
`),
outagesOverlap: db.prepare(`
SELECT started_at, recovered_at FROM outages
WHERE started_at < ? AND (recovered_at IS NULL OR recovered_at > ?)
`),
upsertHourly: db.prepare(`
INSERT INTO hourly_stats (hour, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct)
VALUES (@hour, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct)
ON CONFLICT(hour) DO UPDATE SET
total_polls = excluded.total_polls, avg_ms = excluded.avg_ms,
min_ms = excluded.min_ms, max_ms = excluded.max_ms,
outage_s = excluded.outage_s, uptime_pct = excluded.uptime_pct
`),
upsertDaily: db.prepare(`
INSERT INTO daily_stats (date, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct)
VALUES (@date, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct)
ON CONFLICT(date) DO UPDATE SET
total_polls = excluded.total_polls, avg_ms = excluded.avg_ms,
min_ms = excluded.min_ms, max_ms = excluded.max_ms,
outage_s = excluded.outage_s, uptime_pct = excluded.uptime_pct
`),
};
// ── Console helpers ───────────────────────────────────────────────────────────
const fmt = {
ts: s => chalk.dim(s),
label: s => chalk.bold.cyan(s.padEnd(22)),
value: s => chalk.white(String(s)),
ok: s => chalk.bold.green(s),
warn: s => chalk.bold.yellow(s),
error: s => chalk.bold.red(s),
delta: n => n > 0 ? chalk.bold.magenta(`+${n}`) : chalk.dim('±0'),
counter: n => chalk.bold.yellow(String(n)),
sep: () => chalk.dim('─'.repeat(50)),
};
function logOk(label, data, delta, responseMs) {
const rtt = responseMs != null ? chalk.bold.green(`${responseMs} ms`) : chalk.dim('null');
console.log(fmt.sep());
console.log(fmt.ts(new Date().toISOString()), fmt.ok(`[${label}]`),
chalk.bold.white(data.service), chalk.dim(`v${data.version}`), chalk.dim('·'), rtt);
console.log(fmt.label('signatureCounter:'), fmt.counter(data.signatureCounter), fmt.delta(delta));
console.log(fmt.label('transactionCounter:'), fmt.value(data.transactionCounter));
console.log(fmt.label('registeredClients:'), fmt.value(data.registeredClients));
console.log(fmt.label('initialized:'), data.initialized ? fmt.ok('true') : fmt.warn('false'));
console.log(fmt.label('serialNumber:'), chalk.dim((data.serialNumber?.slice(0, 16) ?? '') + '…'));
console.log(fmt.label('dbPath:'), fmt.value(data.dbPath));
}
function logError(err, responseMs) {
const rtt = responseMs != null ? chalk.bold.red(`${responseMs} ms`) : chalk.dim('null');
console.log(fmt.sep());
console.error(fmt.ts(new Date().toISOString()), fmt.error('[ERROR]'), err.message, chalk.dim('·'), rtt);
}
// ── Poller ────────────────────────────────────────────────────────────────────
let wasError = false;
let isFirstRun = true;
async function poll() {
let data, responseMs = null;
const t0 = Date.now();
try {
const res = await fetch(TSE_URL, { signal: AbortSignal.timeout(8_000) });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
data = await res.json();
responseMs = Date.now() - t0;
} catch (err) {
responseMs = err.name === 'TimeoutError' ? null : Date.now() - t0;
if (!wasError) { logError(err, responseMs); wasError = true; }
return;
}
const prev = lastRow.get();
const delta = prev ? (data.signatureCounter - prev.signature_counter) : 0;
insertEvent.run({
ts: new Date().toISOString(),
status: data.status ?? null,
service: data.service ?? null,
version: data.version ?? null,
serialNumber: data.serialNumber ?? null,
signatureCounter: data.signatureCounter ?? null,
transactionCounter: data.transactionCounter ?? null,
registeredClients: data.registeredClients ?? null,
initialized: data.initialized ? 1 : 0,
createdAt: data.createdAt ?? null,
fccVersion: data.fccVersion ?? null,
dbPath: data.dbPath ?? null,
delta,
responseMs,
});
if (isFirstRun) { logOk('INIT', data, delta, responseMs); isFirstRun = false; }
else if (wasError) { logOk('RECOVERED', data, delta, responseMs); }
wasError = false;
}
// ── Aggregator ────────────────────────────────────────────────────────────────
function msStats(rows) {
const vals = rows.map(r => r.response_ms).filter(v => v != null);
if (!vals.length) return { avg_ms: null, min_ms: null, max_ms: null };
return {
avg_ms: Math.round(vals.reduce((a, b) => a + b, 0) / vals.length),
min_ms: Math.min(...vals),
max_ms: Math.max(...vals),
};
}
function outageSecondsInWindow(winStart, winEnd) {
const wsMs = new Date(winStart).getTime(), weMs = new Date(winEnd).getTime();
return Math.round(
stmts.outagesOverlap.all(winEnd, winStart).reduce((sum, o) => {
const s = Math.max(new Date(o.started_at).getTime(), wsMs);
const e = o.recovered_at ? Math.min(new Date(o.recovered_at).getTime(), weMs) : weMs;
return sum + Math.max(0, (e - s) / 1000);
}, 0)
);
}
function detectOutages(fromTs) {
for (const g of stmts.gapsFrom.all(fromTs, POLL_S, GAP_S)) {
stmts.upsertOutage.run({ started_at: g.started_at, recovered_at: g.recovered_at, duration_s: g.gap_s - POLL_S });
}
}
function* eachHour(fromTs, toTs) {
const cur = new Date(fromTs); cur.setUTCMinutes(0, 0, 0);
const end = new Date(toTs);
while (cur <= end) { yield cur.toISOString(); cur.setUTCHours(cur.getUTCHours() + 1); }
}
function* eachDay(fromTs, toTs) {
const cur = new Date(fromTs); cur.setUTCHours(0, 0, 0, 0);
const end = new Date(toTs);
while (cur <= end) { yield cur.toISOString().slice(0, 10); cur.setUTCDate(cur.getUTCDate() + 1); }
}
function aggregate(fromTs, toTs = new Date().toISOString()) {
detectOutages(fromTs);
for (const h of eachHour(fromTs, toTs)) {
const hourEnd = new Date(new Date(h).getTime() + 3_600_000).toISOString();
const rows = stmts.eventsInRange.all(h, hourEnd);
if (!rows.length) continue; // no events → no row
const { avg_ms, min_ms, max_ms } = msStats(rows);
const outage_s = outageSecondsInWindow(h, hourEnd);
const uptime_pct = +Math.max(0, Math.min(100, (3600 - outage_s) / 3600 * 100)).toFixed(1);
stmts.upsertHourly.run({ hour: h, total_polls: rows.length, avg_ms, min_ms, max_ms, outage_s, uptime_pct });
}
for (const d of eachDay(fromTs, toTs)) {
const dayStart = d + 'T00:00:00Z';
const dayEnd = new Date(new Date(dayStart).getTime() + 86_400_000).toISOString();
const rows = stmts.eventsInRange.all(dayStart, dayEnd);
if (!rows.length) continue; // no events → no row
const { avg_ms, min_ms, max_ms } = msStats(rows);
const outage_s = outageSecondsInWindow(dayStart, dayEnd);
const uptime_pct = +Math.max(0, Math.min(100, (86400 - outage_s) / 86400 * 100)).toFixed(2);
stmts.upsertDaily.run({ date: d, total_polls: rows.length, avg_ms, min_ms, max_ms, outage_s, uptime_pct });
}
}
// ── Express API ───────────────────────────────────────────────────────────────
const app = express();
app.use((_, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
app.get('/api/summary', (_, res) => {
const stats = db.prepare(`
SELECT SUM(total_polls) AS total_polls, ROUND(AVG(avg_ms),0) AS avg_ms,
MIN(min_ms) AS min_ms, MAX(max_ms) AS max_ms,
SUM(outage_s) AS total_outage_s, ROUND(AVG(uptime_pct),2) AS avg_uptime_pct
FROM daily_stats
`).get();
const outage_count = db.prepare('SELECT COUNT(*) AS n FROM outages').get().n;
const live = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get() ?? null;
res.json({ ...stats, outage_count, live });
});
app.get('/api/hourly', (req, res) => {
const days = Math.min(30, Math.max(1, parseInt(req.query.days) || 7));
const from = new Date(Date.now() - days * 86_400_000).toISOString();
res.json(db.prepare('SELECT * FROM hourly_stats WHERE hour >= ? ORDER BY hour').all(from));
});
app.get('/api/daily', (req, res) => {
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
const from = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
res.json(db.prepare('SELECT * FROM daily_stats WHERE date >= ? ORDER BY date').all(from));
});
app.get('/api/outages', (req, res) => {
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
res.json(db.prepare('SELECT * FROM outages ORDER BY started_at DESC LIMIT ?').all(limit));
});
app.get('/api/live', (_, res) => {
res.json(db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get() ?? null);
});
// ── HTTP server (created first so Vite HMR can share it) ─────────────────────
const httpServer = createHttpServer(app);
// ── Vite dev server (programmatic, middleware mode) ───────────────────────────
const vite = await createViteServer({
root: __dirname,
configFile: false,
plugins: [react()],
server: {
middlewareMode: true,
hmr: { server: httpServer },
watch: {
ignored: ['**/tse_events.db*', '**/monitor.js', '**/node_modules/**']
}
},
optimizeDeps: {
include: ['react', 'react-dom', '@mui/material', '@mui/icons-material', 'recharts', '@emotion/react', '@emotion/styled']
},
appType: 'spa',
});
app.use(vite.middlewares); // serves React app + HMR on all non-/api routes
httpServer.listen(PORT, () => {
console.log(chalk.bold.bgCyan.black(' TSE MONITOR ') + ' ' + chalk.dim(TSE_URL));
console.log(chalk.dim(`DB → ${DB_PATH} | poll every ${POLL_MS / 1000}s`));
console.log(chalk.bold.blue(`Dashboard → http://localhost:${PORT}`));
console.log(fmt.sep());
});
// ── Aggregation — backfill then incremental ───────────────────────────────────
{
const { v: minTs } = db.prepare('SELECT MIN(ts) AS v FROM events').get() ?? {};
const { v: maxTs } = db.prepare('SELECT MAX(ts) AS v FROM events').get() ?? {};
if (minTs) {
console.log(chalk.dim(`[agg] back-filling ${minTs}${maxTs}`));
aggregate(minTs, maxTs);
console.log(chalk.dim('[agg] done'));
}
}
setInterval(
() => aggregate(new Date(Date.now() - AGG_LOOKBACK_H * 3_600_000).toISOString()),
AGG_INTERVAL
);
// ── Poll ──────────────────────────────────────────────────────────────────────
poll();
setInterval(poll, POLL_MS);
// ── Graceful shutdown ─────────────────────────────────────────────────────────
process.on('SIGINT', async () => {
console.log('\n' + chalk.bold.red('Shutting down…'));
await vite.close();
httpServer.close();
db.close();
process.exit(0);
});