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 }, allowedHosts: ['moni.sebgreen.net'], 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); });