import { Database } from "bun:sqlite"; import { mkdirSync as fsMkdirSync } from "node:fs"; import { dirname } from "node:path"; import type { CheckFailure, ResolvedTarget, StoredCheckResult, StoredTarget } from "./types"; const CREATE_TARGETS_TABLE = ` CREATE TABLE IF NOT EXISTS targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, type TEXT NOT NULL, target TEXT NOT NULL, config TEXT NOT NULL DEFAULT '{}', interval_ms INTEGER NOT NULL, timeout_ms INTEGER NOT NULL, expect TEXT, grp TEXT NOT NULL DEFAULT 'default' ) `; const CREATE_CHECK_RESULTS_TABLE = ` CREATE TABLE IF NOT EXISTS check_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_id INTEGER NOT NULL, timestamp TEXT NOT NULL, matched INTEGER NOT NULL, duration_ms REAL, status_detail TEXT, failure TEXT, FOREIGN KEY (target_id) REFERENCES targets(id) ) `; const CREATE_INDEX = ` CREATE INDEX IF NOT EXISTS idx_check_results_target_timestamp ON check_results (target_id, timestamp) `; export class ProbeStore { private db: Database; private closed = false; constructor(dbPath: string) { ensureDir(dirname(dbPath)); this.db = new Database(dbPath, { create: true }); this.db.run("PRAGMA journal_mode = WAL"); this.db.run(CREATE_TARGETS_TABLE); this.db.run(CREATE_CHECK_RESULTS_TABLE); this.db.run(CREATE_INDEX); } syncTargets(targets: ResolvedTarget[]): void { if (this.closed) return; const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ id: number; name: string; }>; const existingMap = new Map(existingRows.map((r) => [r.name, r.id])); const configNames = new Set(targets.map((t) => t.name)); const insertStmt = this.db.prepare( "INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ); const updateStmt = this.db.prepare( "UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?", ); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?"); const tx = this.db.transaction(() => { for (const t of targets) { const type = t.type; const target = buildTargetDisplay(t); const config = buildTargetConfig(t); const expect = t.expect ? JSON.stringify(t.expect) : null; if (existingMap.has(t.name)) { updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!); } else { insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group); } } for (const [name, id] of existingMap) { if (!configNames.has(name)) { deleteStmt.run(id); } } }); tx(); } getTargets(): StoredTarget[] { if (this.closed) return []; return this.db .query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id") .all() as StoredTarget[]; } getTargetById(id: number): StoredTarget | null { if (this.closed) return null; return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as StoredTarget | null; } insertCheckResult(result: { targetId: number; timestamp: string; matched: boolean; durationMs: number | null; statusDetail: string | null; failure: CheckFailure | null; }): void { if (this.closed) return; this.db .prepare( "INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)", ) .run( result.targetId, result.timestamp, result.matched ? 1 : 0, result.durationMs, result.statusDetail, result.failure ? JSON.stringify(result.failure) : null, ); } getLatestCheck(targetId: number): StoredCheckResult | null { return this.db .query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1") .get(targetId) as StoredCheckResult | null; } getHistory( targetId: number, from: string, to: string, page = 1, pageSize = 20, ): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } { const countRow = this.db .prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?") .get(targetId, from, to) as { total: number }; const offset = (page - 1) * pageSize; const items = this.db .prepare( "SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", ) .all(targetId, from, to, pageSize, offset) as StoredCheckResult[]; return { items, total: countRow.total, page, pageSize }; } getTargetStats(targetId: number): { totalChecks: number; availability: number; } { const row = this.db .prepare( `SELECT COUNT(*) as totalChecks, COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount FROM check_results WHERE target_id = ?`, ) .get(targetId) as { totalChecks: number; upCount: number }; const totalChecks = row.totalChecks; const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0; return { totalChecks, availability: Math.round(availability * 100) / 100, }; } getTrend( targetId: number, from: string, to: string, ): Array<{ hour: string; avgDurationMs: number | null; availability: number; totalChecks: number; }> { return this.db .prepare( `SELECT strftime('%Y-%m-%dT%H:00:00', timestamp) as hour, AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs, CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability, COUNT(*) as totalChecks FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? GROUP BY hour ORDER BY hour`, ) .all(targetId, from, to) as Array<{ hour: string; avgDurationMs: number | null; availability: number; totalChecks: number; }>; } getSummary(): { total: number; up: number; down: number; lastCheckTime: string | null; } { const targets = this.getTargets(); let up = 0; let down = 0; let lastCheckTime: string | null = null; for (const target of targets) { const latest = this.getLatestCheck(target.id); if (latest) { if (latest.matched) { up++; } else { down++; } if (!lastCheckTime || latest.timestamp > lastCheckTime) { lastCheckTime = latest.timestamp; } } else { down++; } } return { total: targets.length, up, down, lastCheckTime, }; } getRecentSamples( targetId: number, limit: number, ): Array<{ timestamp: string; duration_ms: number | null; matched: number }> { return this.db .prepare( "SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?", ) .all(targetId, limit) as Array<{ timestamp: string; duration_ms: number | null; matched: number; }>; } close(): void { this.closed = true; this.db.close(); } } function buildTargetDisplay(t: ResolvedTarget): string { if (t.type === "http") { return t.http.url; } const parts = [t.command.exec, ...t.command.args]; return `exec ${parts.join(" ")}`; } function buildTargetConfig(t: ResolvedTarget): string { if (t.type === "http") { return JSON.stringify({ url: t.http.url, method: t.http.method, headers: t.http.headers, body: t.http.body, maxBodyBytes: t.http.maxBodyBytes, }); } return JSON.stringify({ exec: t.command.exec, args: t.command.args, cwd: t.command.cwd, env: t.command.env, maxOutputBytes: t.command.maxOutputBytes, }); } function ensureDir(dir: string): void { try { fsMkdirSync(dir, { recursive: true }); } catch { // already exists } }