From 43475ebff3d2e18bc0d3abac679059d9821398a1 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 7 Jul 2026 20:02:01 +0200 Subject: [PATCH] u --- .env.example | 16 +++ dashboard/src/App.jsx | 43 +++++-- dashboard/src/api.js | 11 +- monitor.db | Bin 0 -> 4096 bytes monitor.db-shm | Bin 0 -> 32768 bytes monitor.db-wal | Bin 0 -> 65952 bytes monitor.js | 262 ++++++++++++++++++++++++------------------ package-lock.json | 29 +++-- package.json | 9 +- 9 files changed, 221 insertions(+), 149 deletions(-) create mode 100644 .env.example create mode 100644 monitor.db create mode 100644 monitor.db-shm create mode 100644 monitor.db-wal diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc604f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Endpoints to monitor +# Use the prefix ENDPOINT_ followed by an identifier (e.g., 1, 2, MAIN, API) + +# Example 1: Monitor an HTTP endpoint and check if the JSON response has a specific attribute +ENDPOINT_1_URL=http://example.com/api/health +ENDPOINT_1_EXPECT_JSON_ATTR=status + +# Example 2: Monitor an HTTPS endpoint and check if the raw response contains a specific substring +ENDPOINT_2_URL=https://another.com/ +ENDPOINT_2_EXPECT_SUBSTRING="All systems operational" + +# Optional: You can specify a custom poll interval per endpoint in seconds (defaults to 60) +# ENDPOINT_1_POLL_S=30 + +# Original fallback variable (optional) +# TSE_URL=http://3.78.227.48:20001/ diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index f1ddd74..65ba4c0 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -3,6 +3,7 @@ import { ThemeProvider, CssBaseline, Box, Container, Grid, Typography, ToggleButton, ToggleButtonGroup, Chip, CircularProgress, Alert, IconButton, Tooltip, + Select, MenuItem, FormControl } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import { theme } from './theme.js'; @@ -10,12 +11,14 @@ import SummaryCards from './components/SummaryCards.jsx'; import AvailabilityChart from './components/AvailabilityChart.jsx'; import ResponseTimeChart from './components/ResponseTimeChart.jsx'; import OutageTable from './components/OutageTable.jsx'; -import { fetchSummary, fetchHourly, fetchOutages, fetchLive } from './api.js'; +import { fetchSummary, fetchHourly, fetchOutages, fetchLive, fetchEndpoints } from './api.js'; const RANGE_DAYS = { '24h': 1, '7d': 7, '30d': 30 }; const REFRESH_MS = 60_000; export default function App() { + const [endpoints, setEndpoints] = useState([]); + const [selectedEndpoint, setSelectedEndpoint] = useState(''); const [summary, setSummary] = useState(null); const [hourly, setHourly] = useState([]); const [outages, setOutages] = useState([]); @@ -25,14 +28,22 @@ export default function App() { const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + useEffect(() => { + fetchEndpoints().then(eps => { + setEndpoints(eps); + if (eps.length > 0) setSelectedEndpoint(eps[0].id); + }).catch(e => setError(e.message)); + }, []); + const refresh = useCallback(async () => { + if (!selectedEndpoint) return; const days = RANGE_DAYS[range] ?? 7; try { const [s, h, o, l] = await Promise.all([ - fetchSummary(), - fetchHourly(days), - fetchOutages(50), - fetchLive(), + fetchSummary(selectedEndpoint), + fetchHourly(selectedEndpoint, days), + fetchOutages(selectedEndpoint, 50), + fetchLive(selectedEndpoint), ]); setSummary(s); setHourly(h); setOutages(o); setLive(l); setError(null); @@ -42,7 +53,7 @@ export default function App() { } finally { setLoading(false); } - }, [range]); + }, [range, selectedEndpoint]); useEffect(() => { setLoading(true); @@ -67,10 +78,26 @@ export default function App() { }}> - + ⚡ TSE Monitor + {endpoints.length > 0 && ( + + + + )} + + + {live && ( <> - - )} diff --git a/dashboard/src/api.js b/dashboard/src/api.js index 0b28072..96626b9 100644 --- a/dashboard/src/api.js +++ b/dashboard/src/api.js @@ -6,8 +6,9 @@ async function get(path) { return r.json(); } -export const fetchSummary = () => get('/api/summary'); -export const fetchHourly = (days = 7) => get(`/api/hourly?days=${days}`); -export const fetchDaily = (days = 30)=> get(`/api/daily?days=${days}`); -export const fetchOutages = (limit=50) => get(`/api/outages?limit=${limit}`); -export const fetchLive = () => get('/api/live'); +export const fetchEndpoints = () => get('/api/endpoints'); +export const fetchSummary = (ep) => get(`/api/summary?endpoint=${encodeURIComponent(ep)}`); +export const fetchHourly = (ep, days = 7) => get(`/api/hourly?endpoint=${encodeURIComponent(ep)}&days=${days}`); +export const fetchDaily = (ep, days = 30) => get(`/api/daily?endpoint=${encodeURIComponent(ep)}&days=${days}`); +export const fetchOutages = (ep, limit = 50) => get(`/api/outages?endpoint=${encodeURIComponent(ep)}&limit=${limit}`); +export const fetchLive = (ep) => get(`/api/live?endpoint=${encodeURIComponent(ep)}`); diff --git a/monitor.db b/monitor.db new file mode 100644 index 0000000000000000000000000000000000000000..4a3492ca9ee32d110ce3484c72386f9ff8651a2c GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>iWj6^fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLGPMVA literal 0 HcmV?d00001 diff --git a/monitor.db-shm b/monitor.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..24d39d74e00c5e89fc51a17e4fe9cffa55fc5a16 GIT binary patch literal 32768 zcmeI)yG;Z!5C-762OQ7i!VSoYpao(wI-mtI${<68WCT=j(KSH@1SAvy8O)$PM2aq2 z{Qc6bEz9!SUjfs-oJM6kYZS3qZO=F_aLeqMK*_4Az^>wfFK>oJ#v009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkKcZ~~+3Gck+3!QI=132ZJfj>9<%B?+ z0@XuN2(&3s-9v>yn*#f7KHIAaq`(0M0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk S1PBlyK!5-N0t5*B8-Xv{Y$!hf literal 0 HcmV?d00001 diff --git a/monitor.db-wal b/monitor.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..3e6f8327507efc8d3f56c182bffb3d342afdfe3d GIT binary patch literal 65952 zcmeI*Z)_8F9LMpyZfo~PHWnpinIxA0ag%M?2CNH-fq@kVrHt7{!3WK;9@x-cyY{+( zKA4V$m_YD>kq3qciitj7NQ@>DeSnD?6B3Duix^@ek5HdLz(@G|_3pO2Ias#HpKb5U z=Fx9VU1TgcuC)KYR#8?fa=4pcd!zEbI~`x%v+6$nZR(xhQduv>cpDzHjPokw2Jm?z_$f^p4L*U zi6Nbmul}Jwo`~*_4r&8~{rma`4{Cd(2erPT#Qy%c*v`IaJkeHO)i6`}T-vmfo^2$e zuO;Nh;`C_EsK_Jm1eb;E$!_TjAA}#7L8XN_cj*wVC_eR}irEuPbD)8tvU%20ZwG8o>wQB~4r$~f*s`}L9~V)bG==~YX1 zd8g#3%xd`~r;cFGii3w+M^OGu@8$*m81MPJa;F+3FYw4NLF@YL*i*WA&X_YS-weAv%W{T&YcLq@Uw^+0 zy9X`V<(UXkM-Yq6u3eyx!1F8t_rK6|^@X>dVp%+oASf@kZeE~g@Wj+);MyhX2v*9k zAkGs31Q0*~0R#|0009ILKwyOn$iMxE-1`E?)AxS9*!s|MY@RxT8V`=FAy{-BL3M51S4U7AuN&}en^6Q1KmY** z5I_KdB`NUESSS>3ZdOm)v3R=^^rG{v+82j+W;#)LUvSs$FMT12U73{E+)mx?uG~d0 z-UZ7@C3VrQx;Dr1f-#&MH41i%nWTNHRM4$-&Mf!c?(6J*DBgcyD6088W1rbeInLf* z9Srw1-!IPYoOH6w8L6R;U{?S83(c@oN8o#l@;ri)VGbKDrk*w0ipEIm_&PNdZfa5| z)|3l%j8Vh1iuP;3cRjM_&2>3)DhU>IRq~Ya$Wj0G$tph_!fEormJj!N1m!0|xOst& zug`tneCEmp>IjzPFJKND0R#|0009ILKmY**5I_Kdg$l^O{nxnn1sV=#wnr{q-9a6} zLK$g~00IagfB*srAbSTT#ee_;2q1s} z0tg_000IagfB*tZPGEx?QW{=p3@BaQo!vd{oe}Ys=<40p*|lxU){frIkwdv->REAG z@;QUkd4YU)%dV}5zBTRp0=Jch+e_}mIbZ}3KmY**5I_I{1Q0*~0R#|ukU))E(Ntr1 zs34m8@&c=WTyv)8eB)R0zCfViqR0z~7X}0nKmY**5I_I{1Q0*~0R#|uVgxn>oFqW^ zf0F=_jxCX1kpu|cTwGq@>h{mRIQLQVSvxOqOKG_E#GEz 120 s → outage const AGG_INTERVAL = 2 * 60_000; // re-aggregate every 2 min const AGG_LOOKBACK_H = 3; // hours covered on incremental run +const ENDPOINTS = []; +for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('ENDPOINT_') && key.endsWith('_URL')) { + const id = key.substring(9, key.length - 4); + ENDPOINTS.push({ + id, + url: value, + expectJsonAttr: process.env[`ENDPOINT_${id}_EXPECT_JSON_ATTR`], + expectSubstring: process.env[`ENDPOINT_${id}_EXPECT_SUBSTRING`] + }); + } +} +if (ENDPOINTS.length === 0) { + ENDPOINTS.push({ id: 'DEFAULT', url: process.env.TSE_URL || 'http://3.78.227.48:20001/' }); +} + // ── DB ──────────────────────────────────────────────────────────────────────── const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); @@ -25,72 +41,63 @@ db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, + endpoint_id TEXT NOT NULL, 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 + response_ms INTEGER, + error_msg TEXT ); CREATE TABLE IF NOT EXISTS hourly_stats ( - hour TEXT PRIMARY KEY, + endpoint_id TEXT, + hour TEXT, 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 + uptime_pct REAL, + PRIMARY KEY (endpoint_id, hour) ); CREATE TABLE IF NOT EXISTS daily_stats ( - date TEXT PRIMARY KEY, + endpoint_id TEXT, + date TEXT, 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 + uptime_pct REAL, + PRIMARY KEY (endpoint_id, date) ); CREATE TABLE IF NOT EXISTS outages ( id INTEGER PRIMARY KEY AUTOINCREMENT, - started_at TEXT NOT NULL UNIQUE, + endpoint_id TEXT NOT NULL, + started_at TEXT NOT NULL, recovered_at TEXT, - duration_s INTEGER + duration_s INTEGER, + UNIQUE (endpoint_id, started_at) ); `); // 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) + (endpoint_id, ts, status, response_ms, error_msg) VALUES - (@ts, @status, @service, @version, @serialNumber, - @signatureCounter, @transactionCounter, @registeredClients, - @initialized, @createdAt, @fccVersion, @dbPath, @delta, @responseMs) + (@endpoint_id, @ts, @status, @responseMs, @errorMsg) `); -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' + 'SELECT ts, response_ms FROM events WHERE endpoint_id = ? AND 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 >= ? + FROM events WHERE endpoint_id = ? AND ts >= ? ) SELECT strftime('%Y-%m-%dT%H:%M:%SZ', prev_ts, '+' || ? || ' seconds') AS started_at, @@ -101,28 +108,28 @@ const stmts = { 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 + INSERT INTO outages (endpoint_id, started_at, recovered_at, duration_s) + VALUES (@endpoint_id, @started_at, @recovered_at, @duration_s) + ON CONFLICT(endpoint_id, 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 > ?) + WHERE endpoint_id = ? AND 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 + INSERT INTO hourly_stats (endpoint_id, hour, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct) + VALUES (@endpoint_id, @hour, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct) + ON CONFLICT(endpoint_id, 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 + INSERT INTO daily_stats (endpoint_id, date, total_polls, avg_ms, min_ms, max_ms, outage_s, uptime_pct) + VALUES (@endpoint_id, @date, @total_polls, @avg_ms, @min_ms, @max_ms, @outage_s, @uptime_pct) + ON CONFLICT(endpoint_id, 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 @@ -137,71 +144,85 @@ const fmt = { 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) { +function logOk(label, 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)); + console.log(fmt.ts(new Date().toISOString()), fmt.ok(`[${label}]`), chalk.dim('·'), rtt); } -function logError(err, responseMs) { +function logError(errLabel, 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); + console.error(fmt.ts(new Date().toISOString()), fmt.error('[ERROR]'), errLabel, chalk.dim('·'), rtt); } // ── Poller ──────────────────────────────────────────────────────────────────── -let wasError = false; -let isFirstRun = true; +let wasError = {}; +let isFirstRun = {}; -async function poll() { - let data, responseMs = null; +ENDPOINTS.forEach(ep => { + wasError[ep.id] = false; + isFirstRun[ep.id] = true; +}); + +async function pollEndpoint(ep) { + let data = {}, responseMs = null, errorMsg = null; const t0 = Date.now(); + let ok = true; try { - const res = await fetch(TSE_URL, { signal: AbortSignal.timeout(8_000) }); + const res = await fetch(ep.url, { signal: AbortSignal.timeout(8_000) }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); - data = await res.json(); + const text = await res.text(); responseMs = Date.now() - t0; + + // Check validation criteria + if (ep.expectSubstring && !text.includes(ep.expectSubstring)) { + throw new Error(`Substring '${ep.expectSubstring}' not found`); + } + + // Attempt JSON parse if expectJsonAttr or looks like JSON + if (ep.expectJsonAttr || text.startsWith('{') || text.startsWith('[')) { + try { + data = JSON.parse(text); + if (ep.expectJsonAttr && !(ep.expectJsonAttr in data)) { + throw new Error(`JSON missing attribute '${ep.expectJsonAttr}'`); + } + } catch (e) { + if (ep.expectJsonAttr) throw new Error(`Invalid JSON: ${e.message}`); + } + } } catch (err) { + ok = false; + errorMsg = err.message; responseMs = err.name === 'TimeoutError' ? null : Date.now() - t0; - if (!wasError) { logError(err, responseMs); wasError = true; } - return; + if (!wasError[ep.id]) { logError(`${ep.id} ERROR: ${err.message}`, responseMs); wasError[ep.id] = true; } } - const prev = lastRow.get(); - const delta = prev ? (data.signatureCounter - prev.signature_counter) : 0; - insertEvent.run({ + endpoint_id: ep.id, 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, + status: data.status ?? (ok ? 'ok' : 'error'), responseMs, + errorMsg }); - if (isFirstRun) { logOk('INIT', data, delta, responseMs); isFirstRun = false; } - else if (wasError) { logOk('RECOVERED', data, delta, responseMs); } - wasError = false; + if (isFirstRun[ep.id]) { + if (ok) logOk(`${ep.id} INIT`, responseMs); + isFirstRun[ep.id] = false; + } + else if (ok && wasError[ep.id]) { + logOk(`${ep.id} RECOVERED`, responseMs); + } + if (ok) wasError[ep.id] = false; +} + +function poll() { + for (const ep of ENDPOINTS) { + pollEndpoint(ep); + } } // ── Aggregator ──────────────────────────────────────────────────────────────── @@ -215,10 +236,10 @@ function msStats(rows) { }; } -function outageSecondsInWindow(winStart, winEnd) { +function outageSecondsInWindow(epId, winStart, winEnd) { const wsMs = new Date(winStart).getTime(), weMs = new Date(winEnd).getTime(); return Math.round( - stmts.outagesOverlap.all(winEnd, winStart).reduce((sum, o) => { + stmts.outagesOverlap.all(epId, 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); @@ -226,9 +247,9 @@ function outageSecondsInWindow(winStart, winEnd) { ); } -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 detectOutages(epId, fromTs) { + for (const g of stmts.gapsFrom.all(epId, fromTs, POLL_S, GAP_S)) { + stmts.upsertOutage.run({ endpoint_id: epId, started_at: g.started_at, recovered_at: g.recovered_at, duration_s: g.gap_s - POLL_S }); } } @@ -244,25 +265,27 @@ function* eachDay(fromTs, toTs) { } 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 }); + for (const ep of ENDPOINTS) { + detectOutages(ep.id, 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(ep.id, h, hourEnd); + if (!rows.length) continue; // no events → no row + const { avg_ms, min_ms, max_ms } = msStats(rows); + const outage_s = outageSecondsInWindow(ep.id, h, hourEnd); + const uptime_pct = +Math.max(0, Math.min(100, (3600 - outage_s) / 3600 * 100)).toFixed(1); + stmts.upsertHourly.run({ endpoint_id: ep.id, 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(ep.id, dayStart, dayEnd); + if (!rows.length) continue; // no events → no row + const { avg_ms, min_ms, max_ms } = msStats(rows); + const outage_s = outageSecondsInWindow(ep.id, dayStart, dayEnd); + const uptime_pct = +Math.max(0, Math.min(100, (86400 - outage_s) / 86400 * 100)).toFixed(2); + stmts.upsertDaily.run({ endpoint_id: ep.id, date: d, total_polls: rows.length, avg_ms, min_ms, max_ms, outage_s, uptime_pct }); + } } } @@ -270,37 +293,46 @@ function aggregate(fromTs, toTs = new Date().toISOString()) { const app = express(); app.use((_, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); }); -app.get('/api/summary', (_, res) => { +app.get('/api/endpoints', (_, res) => { + res.json(ENDPOINTS.map(e => ({ id: e.id, url: e.url }))); +}); + +app.get('/api/summary', (req, res) => { + const epId = req.query.endpoint || ENDPOINTS[0].id; 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; + FROM daily_stats WHERE endpoint_id = ? + `).get(epId); + const outage_count = db.prepare('SELECT COUNT(*) AS n FROM outages WHERE endpoint_id = ?').get(epId).n; + const live = db.prepare('SELECT * FROM events WHERE endpoint_id = ? ORDER BY id DESC LIMIT 1').get(epId) ?? null; res.json({ ...stats, outage_count, live }); }); app.get('/api/hourly', (req, res) => { + const epId = req.query.endpoint || ENDPOINTS[0].id; 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)); + res.json(db.prepare('SELECT * FROM hourly_stats WHERE endpoint_id = ? AND hour >= ? ORDER BY hour').all(epId, from)); }); app.get('/api/daily', (req, res) => { + const epId = req.query.endpoint || ENDPOINTS[0].id; 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)); + res.json(db.prepare('SELECT * FROM daily_stats WHERE endpoint_id = ? AND date >= ? ORDER BY date').all(epId, from)); }); app.get('/api/outages', (req, res) => { + const epId = req.query.endpoint || ENDPOINTS[0].id; 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)); + res.json(db.prepare('SELECT * FROM outages WHERE endpoint_id = ? ORDER BY started_at DESC LIMIT ?').all(epId, limit)); }); -app.get('/api/live', (_, res) => { - res.json(db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get() ?? null); +app.get('/api/live', (req, res) => { + const epId = req.query.endpoint || ENDPOINTS[0].id; + res.json(db.prepare('SELECT * FROM events WHERE endpoint_id = ? ORDER BY id DESC LIMIT 1').get(epId) ?? null); }); // ── HTTP server (created first so Vite HMR can share it) ───────────────────── @@ -316,7 +348,7 @@ const vite = await createViteServer({ hmr: { server: httpServer }, allowedHosts: ['moni.sebgreen.net'], watch: { - ignored: ['**/tse_events.db*', '**/monitor.js', '**/node_modules/**'] + ignored: ['**/monitor.db*', '**/tse_events.db*', '**/monitor.js', '**/node_modules/**'] } }, optimizeDeps: { @@ -328,7 +360,7 @@ const vite = await createViteServer({ 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.bold.bgCyan.black(' TSE MONITOR ') + ' ' + chalk.dim(ENDPOINTS.map(e => e.url).join(', '))); 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()); diff --git a/package-lock.json b/package-lock.json index ccd2295..258a5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@mui/material": "^9.2.0", "better-sqlite3": "^12.11.1", "chalk": "^5.6.2", + "dotenv": "^17.4.2", "express": "^5.2.1", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -1597,6 +1598,18 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3566,22 +3579,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/package.json b/package.json index de401b4..ce6917e 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,16 @@ "dev": "node monitor.js" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^9.2.0", + "@mui/material": "^9.2.0", "better-sqlite3": "^12.11.1", "chalk": "^5.6.2", + "dotenv": "^17.4.2", "express": "^5.2.1", "react": "^19.2.7", "react-dom": "^19.2.7", - "@mui/material": "^9.2.0", - "@mui/icons-material": "^9.2.0", - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", "recharts": "^3.9.2" }, "devDependencies": {