diff --git a/.gitignore b/.gitignore index cd2fe3e9..6192a3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ .env *.log -logs/ +/logs/ coverage/ .next/ @@ -13,7 +13,6 @@ coverage/ .claude_settings.json .worktrees/ .security-key -logs/security/ # State persistence data (keep structure, ignore content) data/* diff --git a/package.json b/package.json index 00fd0673..8f01aeb1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "pg": "^8.18.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "winston-transport": "^4.9.0" + "winston-transport": "^4.9.0", + "ws": "^8.19.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7535b6e7..257402ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: winston-transport: specifier: ^4.9.0 version: 4.9.0 + ws: + specifier: ^8.19.0 + version: 8.19.0 devDependencies: '@biomejs/biome': specifier: ^2.4.0 diff --git a/src/api/routes/health.js b/src/api/routes/health.js index 49e13338..b83128fd 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -7,14 +7,44 @@ import { Router } from 'express'; import { isValidSecret } from '../middleware/auth.js'; +/** Lazy-loaded queryLogs — optional diagnostic feature, not required for health */ +let _queryLogs = null; +let queryLogsFailed = false; +async function getQueryLogs() { + if (queryLogsFailed) return null; + if (!_queryLogs) { + try { + const mod = await import('../../utils/logQuery.js'); + _queryLogs = mod.queryLogs; + } catch { + // logQuery not available — tombstone to avoid retrying every request + queryLogsFailed = true; + _queryLogs = null; + } + } + return _queryLogs; +} + const router = Router(); +// Graceful fallback for restartTracker — may not exist yet +let getRestarts = null; +let getRestartPool = null; +try { + const mod = await import('../../utils/restartTracker.js'); + getRestarts = mod.getRestarts ?? null; + const dbMod = await import('../../db.js'); + getRestartPool = dbMod.getPool ?? null; +} catch { + // restartTracker not available yet — fallback to null +} + /** * GET / — Health check endpoint * Returns status, uptime, and Discord connection details. - * Includes detailed memory usage only when a valid x-api-secret header is provided. + * Includes extended data only when a valid x-api-secret header is provided. */ -router.get('/', (req, res) => { +router.get('/', async (req, res) => { const { client } = req.app.locals; // Defensive guard in case health check is hit before Discord login completes @@ -38,6 +68,58 @@ router.get('/', (req, res) => { guilds: client.guilds.cache.size, }; body.memory = process.memoryUsage(); + + body.system = { + platform: process.platform, + nodeVersion: process.version, + cpuUsage: process.cpuUsage(), + }; + + // Error counts from logs table (optional — partial data on failure) + const queryLogs = await getQueryLogs(); + if (queryLogs) { + try { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const [hourResult, dayResult] = await Promise.all([ + queryLogs({ level: 'error', since: oneHourAgo, limit: 1 }), + queryLogs({ level: 'error', since: oneDayAgo, limit: 1 }), + ]); + + body.errors = { + lastHour: hourResult.total, + lastDay: dayResult.total, + }; + } catch { + body.errors = { lastHour: null, lastDay: null, error: 'query failed' }; + } + } else { + body.errors = { lastHour: null, lastDay: null, error: 'log query unavailable' }; + } + + // Restart data with graceful fallback + if (getRestarts && getRestartPool) { + try { + const pool = getRestartPool(); + if (pool) { + const rows = await getRestarts(pool, 20); + body.restarts = rows.map(r => ({ + timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), + reason: r.reason || 'unknown', + version: r.version ?? null, + uptimeBefore: r.uptime_seconds ?? null, + })); + } else { + body.restarts = []; + } + } catch { + body.restarts = []; + } + } else { + body.restarts = []; + } } res.json(body); diff --git a/src/api/server.js b/src/api/server.js index 714c52ea..3a2b122a 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -9,6 +9,7 @@ import apiRouter from './index.js'; import { rateLimit } from './middleware/rateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; import { stopGuildCacheCleanup } from './utils/discordApi.js'; +import { setupLogStream, stopLogStream } from './ws/logStream.js'; /** @type {import('node:http').Server | null} */ let server = null; @@ -85,9 +86,11 @@ export function createApp(client, dbPool) { * * @param {import('discord.js').Client} client - Discord client instance * @param {import('pg').Pool | null} dbPool - PostgreSQL connection pool + * @param {Object} [options] - Additional options + * @param {import('../transports/websocket.js').WebSocketTransport} [options.wsTransport] - WebSocket transport for log streaming * @returns {Promise} The HTTP server instance */ -export async function startServer(client, dbPool) { +export async function startServer(client, dbPool, options = {}) { if (server) { warn('startServer called while a server is already running — closing orphaned server'); await stopServer(); @@ -109,6 +112,17 @@ export async function startServer(client, dbPool) { return new Promise((resolve, reject) => { server = app.listen(port, () => { info('API server started', { port }); + + // Attach WebSocket log stream if transport provided + if (options.wsTransport) { + try { + setupLogStream(server, options.wsTransport); + } catch (err) { + error('Failed to setup WebSocket log stream', { error: err.message }); + // Non-fatal — HTTP server still works without WS streaming + } + } + resolve(server); }); server.once('error', (err) => { @@ -125,6 +139,9 @@ export async function startServer(client, dbPool) { * @returns {Promise} */ export async function stopServer() { + // Stop WebSocket log stream before closing HTTP server + await stopLogStream(); + stopAuthCleanup(); stopGuildCacheCleanup(); diff --git a/src/api/ws/logStream.js b/src/api/ws/logStream.js new file mode 100644 index 00000000..966c5b14 --- /dev/null +++ b/src/api/ws/logStream.js @@ -0,0 +1,367 @@ +/** + * WebSocket Log Stream Server + * + * Manages WebSocket connections for real-time log streaming. + * Handles auth, client lifecycle, per-client filtering, and heartbeat. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; +import WebSocket, { WebSocketServer } from 'ws'; +import { info, error as logError, warn } from '../../logger.js'; +import { queryLogs } from '../../utils/logQuery.js'; + +/** Maximum number of concurrent authenticated clients */ +const MAX_CLIENTS = 10; + +/** Heartbeat ping interval in milliseconds */ +const HEARTBEAT_INTERVAL_MS = 30_000; + +/** Auth timeout — clients must authenticate within this window */ +const AUTH_TIMEOUT_MS = 10_000; + +/** Number of historical log entries to send on connect */ +const HISTORY_LIMIT = 100; + +/** + * @type {WebSocketServer | null} + */ +let wss = null; + +/** + * @type {ReturnType | null} + */ +let heartbeatTimer = null; + +/** + * @type {import('../../transports/websocket.js').WebSocketTransport | null} + */ +let wsTransport = null; + +/** + * Count of currently authenticated clients. + * @type {number} + */ +let authenticatedCount = 0; + +/** + * Set up the WebSocket server for log streaming. + * Attaches to an existing HTTP server on path `/ws/logs`. + * + * @param {import('node:http').Server} httpServer - The HTTP server to attach to + * @param {import('../../transports/websocket.js').WebSocketTransport} transport - The WebSocket Winston transport + */ +export function setupLogStream(httpServer, transport) { + // Guard against double-call — cleanup previous instance first + if (wss) { + warn('setupLogStream called while already running — cleaning up previous instance'); + stopLogStream(); + } + + wsTransport = transport; + + wss = new WebSocketServer({ + server: httpServer, + path: '/ws/logs', + }); + + wss.on('connection', handleConnection); + + // Heartbeat — ping all clients every 30s, terminate dead ones + heartbeatTimer = setInterval(() => { + if (!wss) return; + + for (const ws of wss.clients) { + if (ws.isAlive === false) { + info('Terminating dead WebSocket client', { reason: 'heartbeat timeout' }); + cleanupClient(ws); + ws.terminate(); + continue; + } + ws.isAlive = false; + ws.ping(); + } + }, HEARTBEAT_INTERVAL_MS); + + if (heartbeatTimer.unref) { + heartbeatTimer.unref(); + } + + info('WebSocket log stream server started', { path: '/ws/logs' }); +} + +/** + * Handle a new WebSocket connection. + * Client must authenticate within AUTH_TIMEOUT_MS. + * + * @param {import('ws').WebSocket} ws + */ +function handleConnection(ws) { + ws.isAlive = true; + ws.authenticated = false; + ws.logFilter = null; + + // Set auth timeout + ws.authTimeout = setTimeout(() => { + if (!ws.authenticated) { + ws.close(4001, 'Authentication timeout'); + } + }, AUTH_TIMEOUT_MS); + + ws.on('pong', () => { + ws.isAlive = true; + }); + + ws.on('message', (data) => { + handleMessage(ws, data).catch((err) => { + logError('Unhandled error in WebSocket message handler', { error: err.message }); + }); + }); + + ws.on('close', () => { + cleanupClient(ws); + }); + + ws.on('error', (err) => { + logError('WebSocket client error', { error: err.message }); + cleanupClient(ws); + }); +} + +/** + * Handle an incoming message from a client. + * + * @param {import('ws').WebSocket} ws + * @param {Buffer|string} data + */ +async function handleMessage(ws, data) { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + sendError(ws, 'Invalid JSON'); + return; + } + + if (!msg || typeof msg.type !== 'string') { + sendError(ws, 'Missing message type'); + return; + } + + switch (msg.type) { + case 'auth': + await handleAuth(ws, msg); + break; + + case 'filter': + handleFilter(ws, msg); + break; + + default: + sendError(ws, `Unknown message type: ${msg.type}`); + } +} + +/** + * Validate an HMAC ticket of the form `nonce.expiry.hmac`. + * + * @param {string} ticket - The ticket string from the client + * @param {string} secret - The BOT_API_SECRET used to derive the HMAC + * @returns {boolean} True if the ticket is valid and not expired + */ +function validateTicket(ticket, secret) { + if (typeof ticket !== 'string' || typeof secret !== 'string') return false; + + const parts = ticket.split('.'); + if (parts.length !== 3) return false; + + const [nonce, expiry, hmac] = parts; + if (!nonce || !expiry || !hmac) return false; + + // Check expiry — guard against NaN from non-numeric strings + const expiryNum = Number(expiry); + if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false; + + // Re-derive HMAC and compare with timing-safe equality + const expected = createHmac('sha256', secret) + .update(`${nonce}.${expiry}`) + .digest('hex'); + + try { + return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex')); + } catch { + return false; + } +} + +/** + * Handle auth message. Validates the ticket and sends historical logs. + * + * @param {import('ws').WebSocket} ws + * @param {Object} msg + */ +async function handleAuth(ws, msg) { + if (ws.authenticated) { + sendError(ws, 'Already authenticated'); + return; + } + + if (typeof msg.ticket !== 'string' || !validateTicket(msg.ticket, process.env.BOT_API_SECRET)) { + warn('WebSocket auth failed', { reason: 'invalid ticket' }); + ws.close(4003, 'Authentication failed'); + return; + } + + // Check max client limit + if (authenticatedCount >= MAX_CLIENTS) { + warn('WebSocket max clients reached', { max: MAX_CLIENTS }); + ws.close(4029, 'Too many clients'); + return; + } + + // Auth successful + ws.authenticated = true; + authenticatedCount++; + + if (ws.authTimeout) { + clearTimeout(ws.authTimeout); + ws.authTimeout = null; + } + + sendJson(ws, { type: 'auth_ok' }); + + info('WebSocket client authenticated', { totalClients: authenticatedCount }); + + // Send historical logs BEFORE registering for real-time broadcast + // to prevent race where live logs arrive before history and get overwritten + try { + const { rows } = await queryLogs({ limit: HISTORY_LIMIT }); + // Reverse so oldest comes first (queryLogs returns DESC order) + const logs = rows.reverse().map((row) => ({ + level: row.level, + message: row.message, + metadata: row.metadata || {}, + timestamp: row.timestamp, + module: row.metadata?.module || null, + })); + sendJson(ws, { type: 'history', logs }); + } catch (err) { + logError('Failed to send historical logs', { error: err.message }); + // Non-fatal — real-time streaming still works + sendJson(ws, { type: 'history', logs: [] }); + } + + // Register with transport for real-time log broadcasting AFTER history is sent + if (wsTransport) { + wsTransport.addClient(ws); + } +} + +/** + * Handle filter message. Updates per-client filter. + * + * @param {import('ws').WebSocket} ws + * @param {Object} msg + */ +function handleFilter(ws, msg) { + if (!ws.authenticated) { + sendError(ws, 'Not authenticated'); + return; + } + + ws.logFilter = { + level: typeof msg.level === 'string' ? msg.level : null, + module: typeof msg.module === 'string' ? msg.module : null, + search: typeof msg.search === 'string' ? msg.search : null, + }; + + sendJson(ws, { type: 'filter_ok', filter: ws.logFilter }); +} + +/** + * Clean up a disconnecting client. + * + * @param {import('ws').WebSocket} ws + */ +function cleanupClient(ws) { + if (ws.authTimeout) { + clearTimeout(ws.authTimeout); + ws.authTimeout = null; + } + + if (ws.authenticated) { + ws.authenticated = false; + authenticatedCount = Math.max(0, authenticatedCount - 1); + + if (wsTransport) { + wsTransport.removeClient(ws); + } + + info('WebSocket client disconnected', { totalClients: authenticatedCount }); + } +} + +/** + * Send a JSON message to a client. + * + * @param {import('ws').WebSocket} ws + * @param {Object} data + */ +function sendJson(ws, data) { + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } + } catch { + // Ignore send errors — client cleanup happens elsewhere + } +} + +/** + * Send an error message to a client. + * + * @param {import('ws').WebSocket} ws + * @param {string} message + */ +function sendError(ws, message) { + sendJson(ws, { type: 'error', message }); +} + +/** + * Shut down the WebSocket server. + * Closes all client connections and cleans up resources. + * + * @returns {Promise} + */ +export async function stopLogStream() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + + if (wss) { + // Close all connected clients + for (const ws of wss.clients) { + cleanupClient(ws); + ws.close(1001, 'Server shutting down'); + } + + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + + wss = null; + wsTransport = null; + authenticatedCount = 0; + info('WebSocket log stream server stopped', { module: 'logStream' }); + } +} + +/** + * Get the current count of authenticated clients. + * Useful for health checks and monitoring. + * + * @returns {number} + */ +export function getAuthenticatedClientCount() { + return authenticatedCount; +} diff --git a/src/index.js b/src/index.js index a7a4d23b..aca43c61 100644 --- a/src/index.js +++ b/src/index.js @@ -22,8 +22,8 @@ import { removeLoggingTransport, setInitialTransport, } from './config-listeners.js'; -import { closeDb, initDb } from './db.js'; -import { addPostgresTransport, debug, error, info, warn } from './logger.js'; +import { closeDb, getPool, initDb } from './db.js'; +import { addPostgresTransport, addWebSocketTransport, removeWebSocketTransport, debug, error, info, warn } from './logger.js'; import { getConversationHistory, initConversationHistory, @@ -43,6 +43,7 @@ import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; +import { recordRestart, updateUptimeOnShutdown } from './utils/restartTracker.js'; import { safeFollowUp, safeReply } from './utils/safeSend.js'; // ES module dirname equivalent @@ -53,6 +54,15 @@ const __dirname = dirname(__filename); const dataDir = join(__dirname, '..', 'data'); const statePath = join(dataDir, 'state.json'); +// Package version (for restart tracking) +let BOT_VERSION = 'unknown'; +try { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); + BOT_VERSION = pkg.version; +} catch { + // package.json unreadable — version stays 'unknown' +} + // Load environment variables dotenvConfig(); @@ -257,6 +267,14 @@ async function gracefulShutdown(signal) { error('Failed to close PostgreSQL logging transport', { error: err.message }); } + // 3.5. Record uptime before closing the pool + try { + const pool = getPool(); + await updateUptimeOnShutdown(pool); + } catch (err) { + warn('Failed to record uptime on shutdown', { error: err.message, module: 'shutdown' }); + } + // 4. Close database pool info('Closing database connection'); try { @@ -303,6 +321,9 @@ async function startup() { if (process.env.DATABASE_URL) { dbPool = await initDb(); info('Database initialized'); + + // Record this startup in the restart history table + await recordRestart(dbPool, 'startup', BOT_VERSION); } else { warn('DATABASE_URL not set — using config.json only (no persistence)'); } @@ -401,11 +422,19 @@ async function startup() { await loadCommands(); await client.login(token); - // Start REST API server (non-fatal — bot continues without it) - try { - await startServer(client, dbPool); - } catch (err) { - error('REST API server failed to start — continuing without API', { error: err.message }); + // Start REST API server with WebSocket log streaming (non-fatal — bot continues without it) + { + let wsTransport = null; + try { + wsTransport = addWebSocketTransport(); + await startServer(client, dbPool, { wsTransport }); + } catch (err) { + // Clean up orphaned transport if startServer failed after it was created + if (wsTransport) { + removeWebSocketTransport(wsTransport); + } + error('REST API server failed to start — continuing without API', { error: err.message }); + } } } diff --git a/src/logger.js b/src/logger.js index cd8d3742..73435e7f 100644 --- a/src/logger.js +++ b/src/logger.js @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; import { PostgresTransport } from './transports/postgres.js'; +import { WebSocketTransport } from './transports/websocket.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const configPath = join(__dirname, '..', 'config.json'); @@ -281,6 +282,38 @@ export async function removePostgresTransport(transport) { } } +/** + * Create and add a WebSocket transport to the logger. + * Returns the transport instance so it can be passed to the WS server setup. + * + * @returns {WebSocketTransport} The transport instance + */ +export function addWebSocketTransport() { + const transport = new WebSocketTransport({ + level: logLevel, + format: winston.format.combine( + redactSensitiveData, + winston.format.timestamp(), + winston.format.json(), + ), + }); + + logger.add(transport); + return transport; +} + +/** + * Remove a WebSocket transport from the logger. + * + * @param {WebSocketTransport} transport - The transport to remove + */ +export function removeWebSocketTransport(transport) { + if (transport) { + transport.close(); + logger.remove(transport); + } +} + // Default export for convenience export default { debug, diff --git a/src/transports/websocket.js b/src/transports/websocket.js new file mode 100644 index 00000000..2537f551 --- /dev/null +++ b/src/transports/websocket.js @@ -0,0 +1,165 @@ +/** + * WebSocket Winston Transport + * + * Custom Winston transport that broadcasts log entries to connected + * WebSocket clients in real-time. Zero overhead when no clients are connected. + */ + +import WebSocket from 'ws'; +import Transport from 'winston-transport'; + +/** + * Log level severity ordering (lower = more severe). + * Used for per-client level filtering. + */ +const LEVEL_SEVERITY = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +/** Keys to exclude from metadata extraction — allocated once, not per log() call */ +const EXCLUDED_KEYS = new Set(['level', 'message', 'timestamp', 'splat']); + +/** + * Custom Winston transport that broadcasts log entries to authenticated + * WebSocket clients. Supports per-client filtering by level, module, and search. + */ +export class WebSocketTransport extends Transport { + /** + * @param {Object} [opts] + * @param {string} [opts.level='info'] - Minimum log level + */ + constructor(opts = {}) { + super(opts); + + /** + * Set of authenticated WebSocket clients. + * Each client has a `logFilter` property for per-client filtering. + * @type {Set} + */ + this.clients = new Set(); + } + + /** + * Register an authenticated client for log broadcasting. + * + * @param {import('ws').WebSocket} ws - Authenticated WebSocket client + */ + addClient(ws) { + this.clients.add(ws); + } + + /** + * Remove a client from log broadcasting. + * + * @param {import('ws').WebSocket} ws - WebSocket client to remove + */ + removeClient(ws) { + this.clients.delete(ws); + } + + /** + * Check if a log entry passes a client's filter. + * + * @param {Object} entry - Log entry + * @param {Object} filter - Client's active filter + * @returns {boolean} True if entry passes the filter + */ + passesFilter(entry, filter) { + if (!filter) return true; + + // Level filter — only show logs at or above the client's requested level + if (filter.level) { + const entrySeverity = LEVEL_SEVERITY[entry.level] ?? 3; + const filterSeverity = LEVEL_SEVERITY[filter.level] ?? 3; + if (entrySeverity > filterSeverity) return false; + } + + // Module filter — match metadata.module + if (filter.module && entry.module !== filter.module) { + return false; + } + + // Search filter — case-insensitive substring match on message + if (filter.search) { + const searchLower = filter.search.toLowerCase(); + const messageStr = String(entry.message ?? ''); + if (!messageStr.toLowerCase().includes(searchLower)) { + return false; + } + } + + return true; + } + + /** + * Winston transport log method. + * Broadcasts log entries to all authenticated clients that pass their filter. + * + * @param {Object} info - Winston log info object + * @param {Function} callback - Callback to signal completion + */ + log(info, callback) { + // Zero overhead when no clients connected + if (this.clients.size === 0) { + callback(); + return; + } + + const { level, message, timestamp } = info; + const messageText = typeof message === 'string' ? message : String(message ?? ''); + + // Extract metadata (exclude Winston internal properties + splat symbol) + const metadata = {}; + for (const key of Object.keys(info)) { + if (!EXCLUDED_KEYS.has(key)) { + metadata[key] = info[key]; + } + } + + const entry = { + type: 'log', + level: level || 'info', + message: messageText, + metadata, + timestamp: timestamp || new Date().toISOString(), + module: metadata.module || null, + }; + + let payload; + try { + payload = JSON.stringify(entry); + } catch { + // Non-serializable metadata — send without it + payload = JSON.stringify({ + type: 'log', + level: entry.level, + message: messageText, + metadata: {}, + timestamp: entry.timestamp, + module: null, + }); + } + + for (const ws of this.clients) { + try { + if (ws.readyState === WebSocket.OPEN && this.passesFilter(entry, ws.logFilter)) { + ws.send(payload); + } + } catch { + // Client send failed — will be cleaned up by heartbeat + } + } + + callback(); + } + + /** + * Close the transport and disconnect all clients. + */ + close() { + this.clients.clear(); + } +} diff --git a/src/utils/restartTracker.js b/src/utils/restartTracker.js new file mode 100644 index 00000000..5801ab4d --- /dev/null +++ b/src/utils/restartTracker.js @@ -0,0 +1,161 @@ +/** + * Restart Tracker + * + * Records bot restarts to PostgreSQL and exposes query helpers + * for the dashboard to display restart history. + */ + +import { info, error as logError, warn } from '../logger.js'; + +/** @type {number|null} Startup timestamp in ms for uptime calculation */ +let startedAt = null; + +/** @type {number|null} ID of the most recently inserted restart row */ +let lastRestartId = null; + +/** + * Ensure the bot_restarts table exists. + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool + * @returns {Promise} + */ +async function ensureTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS bot_restarts ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ DEFAULT NOW(), + reason TEXT NOT NULL DEFAULT 'startup', + version TEXT, + uptime_seconds NUMERIC + ) + `); +} + +/** + * Record a bot restart in the database. + * Auto-creates the table if it does not exist. + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool + * @param {string} [reason='startup'] - Human-readable restart reason + * @param {string|null} [version=null] - Bot version string (e.g. from package.json) + * @returns {Promise} The new row ID, or null on failure + */ +export async function recordRestart(pool, reason = 'startup', version = null) { + startedAt = Date.now(); + + try { + await ensureTable(pool); + + const result = await pool.query( + `INSERT INTO bot_restarts (reason, version) VALUES ($1, $2) RETURNING id`, + [reason, version ?? null], + ); + + lastRestartId = result.rows[0]?.id ?? null; + info('Restart recorded', { id: lastRestartId, reason, version }); + return lastRestartId; + } catch (err) { + logError('Failed to record restart', { error: err.message }); + return null; + } +} + +/** + * Update the most recent restart row with the actual uptime when the bot + * shuts down gracefully. + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool + * @returns {Promise} + */ +export async function updateUptimeOnShutdown(pool) { + if (lastRestartId === null || startedAt === null) { + warn('updateUptimeOnShutdown called before recordRestart — skipping', { + module: 'restartTracker', + lastRestartId, + startedAt, + }); + return; + } + + const uptimeSeconds = (Date.now() - startedAt) / 1000; + + try { + await pool.query(`UPDATE bot_restarts SET uptime_seconds = $1 WHERE id = $2`, [ + uptimeSeconds, + lastRestartId, + ]); + info('Uptime recorded on shutdown', { id: lastRestartId, uptimeSeconds }); + } catch (err) { + logError('Failed to update uptime on shutdown', { error: err.message }); + } +} + +/** + * Retrieve recent restart records, newest first. + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool + * @param {number} [limit=20] - Maximum number of rows to return + * @returns {Promise>} + */ +export async function getRestarts(pool, limit = 20) { + try { + const result = await pool.query( + `SELECT id, timestamp, reason, version, uptime_seconds + FROM bot_restarts + ORDER BY timestamp DESC + LIMIT $1`, + [Math.max(1, Math.floor(limit))], + ); + return result.rows; + } catch (err) { + // Self-heal: auto-create table if it doesn't exist, then retry + if (err.code === '42P01') { + try { + await ensureTable(pool); + const result = await pool.query( + `SELECT id, timestamp, reason, version, uptime_seconds + FROM bot_restarts + ORDER BY timestamp DESC + LIMIT $1`, + [Math.max(1, Math.floor(limit))], + ); + return result.rows; + } catch (retryErr) { + logError('Failed to query restarts after table creation', { error: retryErr.message }); + return []; + } + } + logError('Failed to query restarts', { error: err.message }); + return []; + } +} + +/** + * Retrieve the most recent restart record. + * + * @param {import('pg').Pool} pool - PostgreSQL connection pool + * @returns {Promise<{id: number, timestamp: Date, reason: string, version: string|null, uptime_seconds: number|null}|null>} + */ +export async function getLastRestart(pool) { + const rows = await getRestarts(pool, 1); + return rows[0] ?? null; +} + +/** + * Expose the in-memory start timestamp (useful for testing / health checks). + * + * @returns {number|null} + */ +export function getStartedAt() { + return startedAt; +} + +/** + * Reset internal state (used in tests). + * + * @returns {void} + */ +export function _resetState() { + startedAt = null; + lastRestartId = null; +} diff --git a/tests/api/routes/health.test.js b/tests/api/routes/health.test.js index 93932671..a9388f97 100644 --- a/tests/api/routes/health.test.js +++ b/tests/api/routes/health.test.js @@ -7,7 +7,17 @@ vi.mock('../../../src/logger.js', () => ({ error: vi.fn(), })); +vi.mock('../../../src/utils/logQuery.js', () => ({ + queryLogs: vi.fn().mockResolvedValue({ rows: [], total: 0 }), +})); + +// restartTracker doesn't exist yet — mock the attempted import to fail gracefully +vi.mock('../../../src/utils/restartTracker.js', () => { + throw new Error('Module not found'); +}); + import { createApp } from '../../../src/api/server.js'; +import { queryLogs } from '../../../src/utils/logQuery.js'; describe('health route', () => { afterEach(() => { @@ -34,6 +44,9 @@ describe('health route', () => { expect(res.body.uptime).toBeTypeOf('number'); expect(res.body.memory).toBeUndefined(); expect(res.body.discord).toBeUndefined(); + expect(res.body.system).toBeUndefined(); + expect(res.body.errors).toBeUndefined(); + expect(res.body.restarts).toBeUndefined(); }); it('should include memory when valid x-api-secret is provided', async () => { @@ -57,6 +70,9 @@ describe('health route', () => { expect(res.status).toBe(200); expect(res.body.discord).toBeUndefined(); expect(res.body.memory).toBeUndefined(); + expect(res.body.system).toBeUndefined(); + expect(res.body.errors).toBeUndefined(); + expect(res.body.restarts).toBeUndefined(); }); it('should not require authentication', async () => { @@ -66,4 +82,63 @@ describe('health route', () => { expect(res.status).toBe(200); }); + + it('should include system info for authenticated requests', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + const app = buildApp(); + + const res = await request(app).get('/api/v1/health').set('x-api-secret', 'test-secret'); + + expect(res.status).toBe(200); + expect(res.body.system).toBeDefined(); + expect(res.body.system.platform).toBe(process.platform); + expect(res.body.system.nodeVersion).toBe(process.version); + expect(res.body.system.cpuUsage).toBeDefined(); + expect(res.body.system.cpuUsage.user).toBeTypeOf('number'); + expect(res.body.system.cpuUsage.system).toBeTypeOf('number'); + }); + + it('should include error counts for authenticated requests', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + queryLogs + .mockResolvedValueOnce({ rows: [], total: 3 }) // lastHour + .mockResolvedValueOnce({ rows: [], total: 15 }); // lastDay + + const app = buildApp(); + + const res = await request(app).get('/api/v1/health').set('x-api-secret', 'test-secret'); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors.lastHour).toBe(3); + expect(res.body.errors.lastDay).toBe(15); + expect(queryLogs).toHaveBeenCalledTimes(2); + expect(queryLogs).toHaveBeenCalledWith(expect.objectContaining({ level: 'error', limit: 1 })); + }); + + it('should handle queryLogs failure gracefully', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + queryLogs.mockRejectedValueOnce(new Error('db connection failed')); + + const app = buildApp(); + + const res = await request(app).get('/api/v1/health').set('x-api-secret', 'test-secret'); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors.lastHour).toBeNull(); + expect(res.body.errors.lastDay).toBeNull(); + expect(res.body.errors.error).toBe('query failed'); + }); + + it('should include restart data fallback when restartTracker unavailable', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + const app = buildApp(); + + const res = await request(app).get('/api/v1/health').set('x-api-secret', 'test-secret'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.restarts)).toBe(true); + expect(res.body.restarts).toHaveLength(0); + }); }); diff --git a/tests/api/ws/logStream.test.js b/tests/api/ws/logStream.test.js new file mode 100644 index 00000000..ce9460a9 --- /dev/null +++ b/tests/api/ws/logStream.test.js @@ -0,0 +1,320 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import http from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import WebSocket from 'ws'; +import { WebSocketTransport } from '../../../src/transports/websocket.js'; +import { setupLogStream, stopLogStream, getAuthenticatedClientCount } from '../../../src/api/ws/logStream.js'; + +const TEST_SECRET = 'test-api-secret-for-ws'; + +/** + * Generate a valid HMAC ticket for WebSocket auth. + * Format: nonce.expiry.hmac + */ +function makeTicket(secret = TEST_SECRET, ttlMs = 60_000) { + const nonce = randomBytes(16).toString('hex'); + const expiry = String(Date.now() + ttlMs); + const hmac = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex'); + return `${nonce}.${expiry}.${hmac}`; +} + +function createTestServer() { + return new Promise((resolve) => { + const server = http.createServer(); + server.listen(0, () => resolve({ server, port: server.address().port })); + }); +} + +function connectWs(port) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}/ws/logs`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +/** + * Create a message queue for a WebSocket that buffers all incoming messages. + * This prevents the race condition where multiple messages arrive in the same + * TCP segment and fire synchronously before the next `once` handler is registered. + */ +function createMessageQueue(ws) { + const queue = []; + const waiters = []; + + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (waiters.length > 0) { + const waiter = waiters.shift(); + waiter.resolve(msg); + } else { + queue.push(msg); + } + }); + + return { + next(timeoutMs = 3000) { + if (queue.length > 0) { + return Promise.resolve(queue.shift()); + } + return new Promise((resolve, reject) => { + const waiter = { + resolve: (msg) => { + clearTimeout(timer); + resolve(msg); + }, + }; + const timer = setTimeout(() => { + const idx = waiters.indexOf(waiter); + if (idx >= 0) waiters.splice(idx, 1); + reject(new Error('Message timeout')); + }, timeoutMs); + waiters.push(waiter); + }); + }, + }; +} + +function waitForClose(ws, timeoutMs = 3000) { + return new Promise((resolve, reject) => { + if (ws.readyState === WebSocket.CLOSED) return resolve(1000); + const timer = setTimeout(() => reject(new Error('Close timeout')), timeoutMs); + ws.once('close', (code) => { + clearTimeout(timer); + resolve(code); + }); + }); +} + +function sendJson(ws, data) { + ws.send(JSON.stringify(data)); +} + +describe('WebSocket Log Stream', () => { + let httpServer; + let port; + let transport; + let clients; + + beforeEach(async () => { + clients = []; + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + transport = new WebSocketTransport({ level: 'debug' }); + const result = await createTestServer(); + httpServer = result.server; + port = result.port; + setupLogStream(httpServer, transport); + }); + + afterEach(async () => { + for (const ws of clients) { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.terminate(); + } + } + clients = []; + await stopLogStream(); + await new Promise((r) => httpServer.close(r)); + vi.unstubAllEnvs(); + }); + + /** + * Connect and return ws + message queue. + */ + async function connect() { + const ws = await connectWs(port); + clients.push(ws); + const mq = createMessageQueue(ws); + return { ws, mq }; + } + + /** + * Authenticate and consume both auth_ok and history. + */ + async function authenticate(ws, mq) { + sendJson(ws, { type: 'auth', ticket: makeTicket() }); + const authOk = await mq.next(); + expect(authOk.type).toBe('auth_ok'); + const history = await mq.next(); + expect(history.type).toBe('history'); + return history; + } + + describe('authentication', () => { + it('should accept valid auth and send auth_ok + history', async () => { + const { ws, mq } = await connect(); + sendJson(ws, { type: 'auth', ticket: makeTicket() }); + + const authOk = await mq.next(); + expect(authOk.type).toBe('auth_ok'); + + const history = await mq.next(); + expect(history.type).toBe('history'); + expect(Array.isArray(history.logs)).toBe(true); + }); + + it('should reject invalid auth and close connection', async () => { + const { ws } = await connect(); + const closePromise = waitForClose(ws); + sendJson(ws, { type: 'auth', ticket: 'bad.ticket.value' }); + const code = await closePromise; + expect(code).toBe(4003); + }); + + it('should reject auth when already authenticated', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + + sendJson(ws, { type: 'auth', ticket: makeTicket() }); + const errMsg = await mq.next(); + expect(errMsg.type).toBe('error'); + expect(errMsg.message).toBe('Already authenticated'); + }); + + it('should track authenticated client count', async () => { + expect(getAuthenticatedClientCount()).toBe(0); + const { ws, mq } = await connect(); + await authenticate(ws, mq); + expect(getAuthenticatedClientCount()).toBe(1); + }); + + it('should enforce max client limit (10)', async () => { + for (let i = 0; i < 10; i++) { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + } + expect(getAuthenticatedClientCount()).toBe(10); + + const { ws: ws11 } = await connect(); + const closePromise = waitForClose(ws11); + sendJson(ws11, { type: 'auth', ticket: makeTicket() }); + const code = await closePromise; + expect(code).toBe(4029); + }); + }); + + describe('real-time streaming', () => { + it('should stream logs to authenticated clients via transport', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + + transport.log( + { level: 'info', message: 'real-time log', timestamp: '2026-01-01T00:00:00Z', module: 'test' }, + vi.fn(), + ); + + const logMsg = await mq.next(); + expect(logMsg.type).toBe('log'); + expect(logMsg.level).toBe('info'); + expect(logMsg.message).toBe('real-time log'); + expect(logMsg.module).toBe('test'); + }); + + it('should not stream logs to unauthenticated clients', async () => { + await connect(); // don't authenticate + expect(transport.clients.size).toBe(0); + + const callback = vi.fn(); + transport.log({ level: 'info', message: 'should not arrive' }, callback); + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('filtering', () => { + it('should apply per-client level filter', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + + sendJson(ws, { type: 'filter', level: 'error' }); + const filterOk = await mq.next(); + expect(filterOk.type).toBe('filter_ok'); + expect(filterOk.filter.level).toBe('error'); + + transport.log({ level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + const logMsg = await mq.next(); + expect(logMsg.level).toBe('error'); + expect(logMsg.message).toBe('error log'); + }); + + it('should filter out logs below the requested level', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + + sendJson(ws, { type: 'filter', level: 'error' }); + await mq.next(); // filter_ok + + // Info log should be filtered; send error right after to prove it works + transport.log({ level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log({ level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + + const logMsg = await mq.next(); + expect(logMsg.message).toBe('arrives'); + }); + + it('should reject filter from unauthenticated client', async () => { + const { ws, mq } = await connect(); + sendJson(ws, { type: 'filter', level: 'error' }); + const errMsg = await mq.next(); + expect(errMsg.type).toBe('error'); + expect(errMsg.message).toBe('Not authenticated'); + }); + }); + + describe('message handling', () => { + it('should return error for invalid JSON', async () => { + const { ws, mq } = await connect(); + ws.send('not json'); + const errMsg = await mq.next(); + expect(errMsg.type).toBe('error'); + expect(errMsg.message).toBe('Invalid JSON'); + }); + + it('should return error for missing message type', async () => { + const { ws, mq } = await connect(); + sendJson(ws, { data: 'hello' }); + const errMsg = await mq.next(); + expect(errMsg.type).toBe('error'); + expect(errMsg.message).toBe('Missing message type'); + }); + + it('should return error for unknown message type', async () => { + const { ws, mq } = await connect(); + sendJson(ws, { type: 'unknown' }); + const errMsg = await mq.next(); + expect(errMsg.type).toBe('error'); + expect(errMsg.message).toContain('Unknown message type'); + }); + }); + + describe('client lifecycle', () => { + it('should decrement count when client disconnects', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + expect(getAuthenticatedClientCount()).toBe(1); + + const closed = new Promise((r) => ws.once('close', r)); + ws.close(); + await closed; + await new Promise((r) => setTimeout(r, 50)); + expect(getAuthenticatedClientCount()).toBe(0); + }); + }); + + describe('stopLogStream', () => { + it('should close all connections and reset state', async () => { + const { ws, mq } = await connect(); + await authenticate(ws, mq); + expect(getAuthenticatedClientCount()).toBe(1); + + const closePromise = waitForClose(ws); + await stopLogStream(); + await closePromise; + expect(getAuthenticatedClientCount()).toBe(0); + }); + + it('should handle being called when not started', async () => { + await stopLogStream(); + await stopLogStream(); + }); + }); +}); diff --git a/tests/index.test.js b/tests/index.test.js index e629274a..9bbb7b5e 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -237,12 +237,14 @@ async function importIndex({ }); mocks.fs.mkdirSync.mockReset(); mocks.fs.readdirSync.mockReset().mockReturnValue(readdirFiles); - mocks.fs.readFileSync - .mockReset() - .mockReturnValue( + mocks.fs.readFileSync.mockReset().mockImplementation((path) => { + // Return valid package.json for version reads regardless of other state + if (String(path).endsWith('package.json')) return JSON.stringify({ version: '1.0.0' }); + return ( stateRaw ?? - JSON.stringify({ conversationHistory: [['ch1', [{ role: 'user', content: 'hi' }]]] }), + JSON.stringify({ conversationHistory: [['ch1', [{ role: 'user', content: 'hi' }]]] }) ); + }); mocks.fs.writeFileSync.mockReset(); mocks.logger.info.mockReset(); diff --git a/tests/transports/websocket.test.js b/tests/transports/websocket.test.js new file mode 100644 index 00000000..422bd4cc --- /dev/null +++ b/tests/transports/websocket.test.js @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebSocketTransport } from '../../src/transports/websocket.js'; + +/** + * Create a mock WebSocket client. + */ +function createMockWs(readyState = 1) { + return { + readyState, + logFilter: null, + send: vi.fn(), + }; +} + +describe('WebSocketTransport', () => { + let transport; + + beforeEach(() => { + transport = new WebSocketTransport({ level: 'debug' }); + }); + + afterEach(() => { + transport.close(); + }); + + describe('constructor', () => { + it('should initialize with an empty client set', () => { + expect(transport.clients.size).toBe(0); + }); + }); + + describe('addClient / removeClient', () => { + it('should add a client', () => { + const ws = createMockWs(); + transport.addClient(ws); + expect(transport.clients.size).toBe(1); + }); + + it('should remove a client', () => { + const ws = createMockWs(); + transport.addClient(ws); + transport.removeClient(ws); + expect(transport.clients.size).toBe(0); + }); + + it('should not error when removing a non-existent client', () => { + const ws = createMockWs(); + expect(() => transport.removeClient(ws)).not.toThrow(); + }); + }); + + describe('log', () => { + it('should call callback immediately when no clients connected', () => { + const callback = vi.fn(); + transport.log({ level: 'info', message: 'test' }, callback); + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should broadcast to connected authenticated clients', () => { + const ws = createMockWs(); + transport.addClient(ws); + + const callback = vi.fn(); + transport.log({ level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, callback); + + expect(ws.send).toHaveBeenCalledOnce(); + const sent = JSON.parse(ws.send.mock.calls[0][0]); + expect(sent.type).toBe('log'); + expect(sent.level).toBe('info'); + expect(sent.message).toBe('hello world'); + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should not send to clients with closed connections', () => { + const ws = createMockWs(3); // CLOSED state + transport.addClient(ws); + + const callback = vi.fn(); + transport.log({ level: 'info', message: 'test' }, callback); + + expect(ws.send).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should broadcast to multiple clients', () => { + const ws1 = createMockWs(); + const ws2 = createMockWs(); + transport.addClient(ws1); + transport.addClient(ws2); + + transport.log({ level: 'info', message: 'test' }, vi.fn()); + + expect(ws1.send).toHaveBeenCalledOnce(); + expect(ws2.send).toHaveBeenCalledOnce(); + }); + + it('should handle send errors gracefully', () => { + const ws = createMockWs(); + ws.send.mockImplementation(() => { throw new Error('send failed'); }); + transport.addClient(ws); + + const callback = vi.fn(); + expect(() => transport.log({ level: 'info', message: 'test' }, callback)).not.toThrow(); + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should extract metadata from info object', () => { + const ws = createMockWs(); + transport.addClient(ws); + + transport.log({ + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + module: 'api', + userId: '123', + }, vi.fn()); + + const sent = JSON.parse(ws.send.mock.calls[0][0]); + expect(sent.metadata.module).toBe('api'); + expect(sent.metadata.userId).toBe('123'); + expect(sent.module).toBe('api'); + }); + + it('should handle non-serializable metadata', () => { + const ws = createMockWs(); + transport.addClient(ws); + + const circular = {}; + circular.self = circular; + + transport.log({ + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + data: circular, + }, vi.fn()); + + // Should still send — falls back to empty metadata + expect(ws.send).toHaveBeenCalledOnce(); + const sent = JSON.parse(ws.send.mock.calls[0][0]); + expect(sent.metadata).toEqual({}); + }); + }); + + describe('passesFilter', () => { + it('should pass all entries when no filter is set', () => { + const result = transport.passesFilter({ level: 'debug', message: 'test' }, null); + expect(result).toBe(true); + }); + + it('should filter by level severity', () => { + const filter = { level: 'warn' }; + + expect(transport.passesFilter({ level: 'error', message: 'test' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'warn', message: 'test' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'test' }, filter)).toBe(false); + expect(transport.passesFilter({ level: 'debug', message: 'test' }, filter)).toBe(false); + }); + + it('should filter by module', () => { + const filter = { module: 'api' }; + + expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter)).toBe(false); + }); + + it('should filter by search (case-insensitive)', () => { + const filter = { search: 'ERROR' }; + + expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'All good' }, filter)).toBe(false); + }); + + it('should combine multiple filters with AND logic', () => { + const filter = { level: 'warn', module: 'api' }; + + // Passes both + expect(transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter)).toBe(true); + // Fails level + expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(false); + // Fails module + expect(transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter)).toBe(false); + }); + + it('should apply per-client filters during broadcast', () => { + const wsAll = createMockWs(); + wsAll.logFilter = null; // No filter — gets everything + + const wsErrorOnly = createMockWs(); + wsErrorOnly.logFilter = { level: 'error' }; + + transport.addClient(wsAll); + transport.addClient(wsErrorOnly); + + // Send an info-level log + transport.log({ level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + + expect(wsAll.send).toHaveBeenCalledOnce(); + expect(wsErrorOnly.send).not.toHaveBeenCalled(); + + // Send an error-level log + transport.log({ level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + + expect(wsAll.send).toHaveBeenCalledTimes(2); + expect(wsErrorOnly.send).toHaveBeenCalledOnce(); + }); + }); + + describe('close', () => { + it('should clear all clients', () => { + transport.addClient(createMockWs()); + transport.addClient(createMockWs()); + + transport.close(); + expect(transport.clients.size).toBe(0); + }); + }); +}); diff --git a/tests/utils/restartTracker.test.js b/tests/utils/restartTracker.test.js new file mode 100644 index 00000000..407da2b0 --- /dev/null +++ b/tests/utils/restartTracker.test.js @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger — must be defined before imports that use it +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Lazily import after mocks are set up +let recordRestart, updateUptimeOnShutdown, getRestarts, getLastRestart, getStartedAt, _resetState; + +/** + * Build a minimal pg pool mock. + * `queryResponses` is a map of SQL fragment → result object. + */ +function makePool(queryResponses = {}) { + return { + query: vi.fn(async (sql, _params) => { + for (const [fragment, result] of Object.entries(queryResponses)) { + if (sql.includes(fragment)) return result; + } + return { rows: [], rowCount: 0 }; + }), + }; +} + +describe('restartTracker', () => { + beforeEach(async () => { + vi.resetModules(); + // Re-import fresh module so module-level state is reset + const mod = await import('../../src/utils/restartTracker.js'); + recordRestart = mod.recordRestart; + updateUptimeOnShutdown = mod.updateUptimeOnShutdown; + getRestarts = mod.getRestarts; + getLastRestart = mod.getLastRestart; + getStartedAt = mod.getStartedAt; + _resetState = mod._resetState; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // recordRestart + // --------------------------------------------------------------------------- + + describe('recordRestart', () => { + it('creates the table then inserts a row, returns the new id', async () => { + const pool = makePool({ + 'RETURNING id': { rows: [{ id: 42 }] }, + }); + + const id = await recordRestart(pool, 'startup', '1.0.0'); + + expect(id).toBe(42); + // First call: CREATE TABLE IF NOT EXISTS + expect(pool.query.mock.calls[0][0]).toContain('CREATE TABLE IF NOT EXISTS bot_restarts'); + // Second call: INSERT + const insertCall = pool.query.mock.calls[1]; + expect(insertCall[0]).toContain('INSERT INTO bot_restarts'); + expect(insertCall[1]).toEqual(['startup', '1.0.0']); + }); + + it('sets startedAt to a recent timestamp', async () => { + const before = Date.now(); + const pool = makePool({ 'RETURNING id': { rows: [{ id: 1 }] } }); + + await recordRestart(pool); + + const after = Date.now(); + const started = getStartedAt(); + expect(started).toBeGreaterThanOrEqual(before); + expect(started).toBeLessThanOrEqual(after); + }); + + it('defaults reason to "startup" and version to null', async () => { + const pool = makePool({ 'RETURNING id': { rows: [{ id: 1 }] } }); + + await recordRestart(pool); + + const insertCall = pool.query.mock.calls[1]; + expect(insertCall[1]).toEqual(['startup', null]); + }); + + it('returns null and logs error when query throws', async () => { + const pool = { query: vi.fn().mockRejectedValue(new Error('db down')) }; + const { error: logError } = await import('../../src/logger.js'); + + const id = await recordRestart(pool); + + expect(id).toBeNull(); + expect(logError).toHaveBeenCalledWith( + 'Failed to record restart', + expect.objectContaining({ error: 'db down' }), + ); + }); + }); + + // --------------------------------------------------------------------------- + // updateUptimeOnShutdown + // --------------------------------------------------------------------------- + + describe('updateUptimeOnShutdown', () => { + it('updates the restart row with uptime_seconds', async () => { + const pool = makePool({ 'RETURNING id': { rows: [{ id: 7 }] } }); + await recordRestart(pool, 'startup', null); + + // Small artificial delay so uptime > 0 + await new Promise((r) => setTimeout(r, 10)); + + const updatePool = makePool({}); + await updateUptimeOnShutdown(updatePool); + + const [sql, params] = updatePool.query.mock.calls[0]; + expect(sql).toContain('UPDATE bot_restarts SET uptime_seconds'); + expect(params[0]).toBeGreaterThan(0); // uptime > 0 + expect(params[1]).toBe(7); // correct row id + }); + + it('warns and skips when called before recordRestart', async () => { + // Module freshly loaded — no recordRestart has run yet + const pool = makePool({}); + const { warn } = await import('../../src/logger.js'); + + await updateUptimeOnShutdown(pool); + + expect(pool.query).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('updateUptimeOnShutdown called before recordRestart'), + expect.objectContaining({ module: 'restartTracker' }), + ); + }); + + it('logs error but does not throw when update query fails', async () => { + const pool = makePool({ 'RETURNING id': { rows: [{ id: 3 }] } }); + await recordRestart(pool); + + const badPool = { query: vi.fn().mockRejectedValue(new Error('write fail')) }; + const { error: logError } = await import('../../src/logger.js'); + + await expect(updateUptimeOnShutdown(badPool)).resolves.toBeUndefined(); + expect(logError).toHaveBeenCalledWith( + 'Failed to update uptime on shutdown', + expect.objectContaining({ error: 'write fail' }), + ); + }); + }); + + // --------------------------------------------------------------------------- + // getRestarts + // --------------------------------------------------------------------------- + + describe('getRestarts', () => { + it('returns rows from the database', async () => { + const rows = [ + { id: 2, timestamp: new Date(), reason: 'startup', version: '1.0.0', uptime_seconds: 300 }, + { id: 1, timestamp: new Date(), reason: 'startup', version: '1.0.0', uptime_seconds: 120 }, + ]; + const pool = makePool({ 'FROM bot_restarts': { rows } }); + + const result = await getRestarts(pool); + + expect(result).toEqual(rows); + const [sql, params] = pool.query.mock.calls[0]; + expect(sql).toContain('ORDER BY timestamp DESC'); + expect(params[0]).toBe(20); // default limit + }); + + it('respects custom limit', async () => { + const pool = makePool({ 'FROM bot_restarts': { rows: [] } }); + + await getRestarts(pool, 5); + + expect(pool.query.mock.calls[0][1][0]).toBe(5); + }); + + it('clamps fractional and tiny limits to at least 1', async () => { + const pool = makePool({ 'FROM bot_restarts': { rows: [] } }); + + await getRestarts(pool, 0.9); + + expect(pool.query.mock.calls[0][1][0]).toBe(1); + }); + + it('returns empty array and logs error on query failure', async () => { + const pool = { query: vi.fn().mockRejectedValue(new Error('oops')) }; + const { error: logError } = await import('../../src/logger.js'); + + const result = await getRestarts(pool); + + expect(result).toEqual([]); + expect(logError).toHaveBeenCalledWith( + 'Failed to query restarts', + expect.objectContaining({ error: 'oops' }), + ); + }); + + it('self-heals by creating table on 42P01 then retries successfully', async () => { + const rows = [ + { id: 1, timestamp: new Date(), reason: 'startup', version: '1.0.0', uptime_seconds: 60 }, + ]; + let selectCallCount = 0; + const pool = { + query: vi.fn(async (sql) => { + if (sql.includes('FROM bot_restarts')) { + selectCallCount++; + if (selectCallCount === 1) { + const err = new Error('relation "bot_restarts" does not exist'); + err.code = '42P01'; + throw err; + } + // Retry SELECT succeeds + return { rows }; + } + // CREATE TABLE call + if (sql.includes('CREATE TABLE')) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }), + }; + + const result = await getRestarts(pool); + + expect(result).toEqual(rows); + // Should have called: SELECT (fail), CREATE TABLE, SELECT (success) + expect(pool.query).toHaveBeenCalledTimes(3); + }); + + it('returns [] and logs error when retry SELECT also fails after 42P01 self-heal', async () => { + const { error: logError } = await import('../../src/logger.js'); + let selectCallCount = 0; + const pool = { + query: vi.fn(async (sql) => { + if (sql.includes('FROM bot_restarts')) { + selectCallCount++; + if (selectCallCount === 1) { + const err = new Error('relation "bot_restarts" does not exist'); + err.code = '42P01'; + throw err; + } + // Retry also fails + throw new Error('still broken'); + } + // CREATE TABLE succeeds + if (sql.includes('CREATE TABLE')) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }), + }; + + const result = await getRestarts(pool); + + expect(result).toEqual([]); + expect(logError).toHaveBeenCalledWith( + 'Failed to query restarts after table creation', + expect.objectContaining({ error: 'still broken' }), + ); + }); + }); + + // --------------------------------------------------------------------------- + // getLastRestart + // --------------------------------------------------------------------------- + + describe('getLastRestart', () => { + it('returns the single most recent row', async () => { + const row = { + id: 9, + timestamp: new Date(), + reason: 'startup', + version: null, + uptime_seconds: null, + }; + const pool = makePool({ 'FROM bot_restarts': { rows: [row] } }); + + const result = await getLastRestart(pool); + + expect(result).toEqual(row); + // Limit of 1 was passed through + expect(pool.query.mock.calls[0][1][0]).toBe(1); + }); + + it('returns null when no restarts exist', async () => { + const pool = makePool({ 'FROM bot_restarts': { rows: [] } }); + + const result = await getLastRestart(pool); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/web/src/app/api/bot-health/route.ts b/web/src/app/api/bot-health/route.ts new file mode 100644 index 00000000..dd99a6bc --- /dev/null +++ b/web/src/app/api/bot-health/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { getBotApiBaseUrl } from "@/lib/bot-api"; +import { logger } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +/** Request timeout for health proxy calls (10 seconds). */ +const REQUEST_TIMEOUT_MS = 10_000; + +export async function GET(request: NextRequest) { + const token = await getToken({ req: request }); + + if (typeof token?.accessToken !== "string" || token.accessToken.length === 0) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (token.error === "RefreshTokenError") { + return NextResponse.json( + { error: "Token expired. Please sign in again." }, + { status: 401 }, + ); + } + + const botApiBaseUrl = getBotApiBaseUrl(); + const botApiSecret = process.env.BOT_API_SECRET; + + if (!botApiBaseUrl || !botApiSecret) { + const missing = [ + !botApiBaseUrl && "BOT_API_URL", + !botApiSecret && "BOT_API_SECRET", + ].filter(Boolean); + logger.error("[api/bot-health] Missing required env vars", { missing }); + return NextResponse.json( + { error: "Bot API is not configured" }, + { status: 500 }, + ); + } + + let upstreamUrl: URL; + try { + upstreamUrl = new URL(`${botApiBaseUrl}/health`); + } catch { + logger.error("[api/bot-health] Invalid BOT_API_URL", { botApiBaseUrl }); + return NextResponse.json( + { error: "Bot API is not configured correctly" }, + { status: 500 }, + ); + } + + try { + const response = await fetch(upstreamUrl.toString(), { + headers: { + "x-api-secret": botApiSecret, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + cache: "no-store", + }); + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data: unknown = await response.json(); + return NextResponse.json(data, { status: response.status }); + } + + const text = await response.text(); + return NextResponse.json( + { error: text || "Unexpected response from bot API" }, + { status: response.status }, + ); + } catch (error) { + logger.error("[api/bot-health] Failed to proxy health data", { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Failed to fetch health data" }, + { status: 500 }, + ); + } +} diff --git a/web/src/app/api/log-stream/ws-ticket/route.ts b/web/src/app/api/log-stream/ws-ticket/route.ts new file mode 100644 index 00000000..fd9fcfa3 --- /dev/null +++ b/web/src/app/api/log-stream/ws-ticket/route.ts @@ -0,0 +1,77 @@ +import { randomBytes, createHmac } from "node:crypto"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { logger } from "@/lib/logger"; + +export const dynamic = "force-dynamic"; + +/** Ticket lifetime — 30 seconds is plenty to open a WebSocket. */ +const TICKET_TTL_MS = 30_000; + +/** + * Generate a short-lived HMAC ticket the WS server can validate + * without the browser ever seeing the raw secret. + * + * Format: `..` + * + * The bot WS server recreates the HMAC from (nonce + expiry) using the + * shared BOT_API_SECRET and verifies it matches + isn't expired. + */ +function createTicket(secret: string): string { + const nonce = randomBytes(16).toString("hex"); + const expiry = Date.now() + TICKET_TTL_MS; + const payload = `${nonce}.${expiry}`; + const hmac = createHmac("sha256", secret).update(payload).digest("hex"); + return `${payload}.${hmac}`; +} + +/** + * Returns WebSocket connection info for the log stream. + * + * Validates the session, generates a short-lived HMAC ticket, and returns + * the WS URL + ticket. The raw BOT_API_SECRET never leaves the server. + */ +export async function GET(request: NextRequest) { + const token = await getToken({ req: request }); + + if (typeof token?.accessToken !== "string" || token.accessToken.length === 0) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (token.error === "RefreshTokenError") { + return NextResponse.json( + { error: "Token expired. Please sign in again." }, + { status: 401 }, + ); + } + + const botApiUrl = process.env.BOT_API_URL; + const botApiSecret = process.env.BOT_API_SECRET; + + if (!botApiUrl || !botApiSecret) { + logger.error("[api/logs/ws-ticket] BOT_API_URL and BOT_API_SECRET are required"); + return NextResponse.json( + { error: "Bot API is not configured" }, + { status: 500 }, + ); + } + + // Convert http(s):// to ws(s):// for WebSocket connection + let wsUrl: string; + try { + const url = new URL(botApiUrl.replace(/\/+$/, "")); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + wsUrl = `${url.origin}/ws/logs`; + } catch { + logger.error("[api/logs/ws-ticket] Invalid BOT_API_URL", { botApiUrl }); + return NextResponse.json( + { error: "Bot API is not configured correctly" }, + { status: 500 }, + ); + } + + const ticket = createTicket(botApiSecret); + + return NextResponse.json({ wsUrl, ticket }); +} diff --git a/web/src/app/dashboard/logs/page.tsx b/web/src/app/dashboard/logs/page.tsx new file mode 100644 index 00000000..8965e773 --- /dev/null +++ b/web/src/app/dashboard/logs/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { LogViewer } from "@/components/dashboard/log-viewer"; +import { LogFilters } from "@/components/dashboard/log-filters"; +import { HealthSection } from "@/components/dashboard/health-section"; +import { useLogStream } from "@/lib/log-ws"; + +/** + * /dashboard/logs — Real-time log viewer and health monitoring page. + * + * Connects to the bot's /ws/logs WebSocket endpoint (authenticated via + * /api/log-stream/ws-ticket) and streams logs in a terminal-style UI. + * Also displays health cards and restart history. + */ +export default function LogsPage() { + const { logs, status, sendFilter, clearLogs } = useLogStream(); + + return ( +
+ {/* Health cards + restart history */} + + + {/* Log stream section */} +
+
+
+

Log Stream

+

+ Real-time logs from the bot API +

+
+
+ + + +
+ +
+
+
+ ); +} diff --git a/web/src/components/dashboard/health-cards.tsx b/web/src/components/dashboard/health-cards.tsx new file mode 100644 index 00000000..22227ba1 --- /dev/null +++ b/web/src/components/dashboard/health-cards.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { + Activity, + AlertTriangle, + Clock, + Cpu, + Globe, + MemoryStick, + Server, + Wifi, +} from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { BotHealth } from "./types"; +import { formatUptime } from "@/lib/format-time"; + +interface HealthCardsProps { + health: BotHealth | null; + loading: boolean; +} + +function formatBytes(bytes: number): string { + return `${(bytes / 1_048_576).toFixed(1)} MB`; +} + +function pingColor(ping: number): string { + if (ping < 100) return "text-green-500"; + if (ping <= 300) return "text-yellow-500"; + return "text-red-500"; +} + +function errorColor(count: number): string { + return count > 0 ? "text-red-500" : "text-foreground"; +} + +function SkeletonCard() { + return ( + + + + + + + + + ); +} + +export function HealthCards({ health, loading }: HealthCardsProps) { + if (loading && !health) { + return ( +
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+ ); + } + + const heapUsedMb = health ? health.memory.heapUsed / 1_048_576 : 0; + const heapTotalMb = health ? health.memory.heapTotal / 1_048_576 : 0; + const heapPct = heapTotalMb > 0 ? (heapUsedMb / heapTotalMb) * 100 : 0; + + // cpuUsage is cumulative microseconds from process.cpuUsage(), not a percentage. + // Display as total CPU seconds consumed since process start. + const cpuUserSec = health ? health.system.cpuUsage.user / 1_000_000 : 0; + const cpuSystemSec = health ? health.system.cpuUsage.system / 1_000_000 : 0; + const cpuTotalSec = cpuUserSec + cpuSystemSec; + // Show utilization estimate: total CPU time / wall-clock uptime + // Clamp to 0-100 to handle multi-core environments where raw value can exceed 100% + const rawPct = health && health.uptime > 0 + ? (cpuTotalSec / health.uptime) * 100 + : 0; + const cpuPct = Math.min(Math.max(rawPct, 0), 100).toFixed(1); + + return ( +
+ {/* Uptime */} + + + + + Uptime + + + + + {health ? formatUptime(health.uptime) : "—"} + + + + + {/* Memory */} + + + + + Memory + + + + + {health ? formatBytes(health.memory.heapUsed) : "—"} + + {health ? ( + <> +

+ of {formatBytes(health.memory.heapTotal)} ({heapPct.toFixed(0)}%) +

+
+
+
+ + ) : null} + + + + {/* Discord Ping */} + + + + + Discord Ping + + + + + {health ? `${health.discord.ping}ms` : "—"} + + + + + {/* Guilds */} + + + + + Guilds + + + + + {health ? health.discord.guilds.toLocaleString() : "—"} + + + + + {/* Errors (1h) */} + + + + + Errors (1h) + + + + + {health ? (health.errors.lastHour?.toLocaleString() ?? "—") : "—"} + + + + + {/* Errors (24h) */} + + + + + Errors (24h) + + + + + {health ? (health.errors.lastDay?.toLocaleString() ?? "—") : "—"} + + + + + {/* CPU — estimated utilisation from cumulative cpuUsage / uptime */} + + + + + CPU + + + + + {health ? `${cpuPct}%` : "—"} + + {health ? ( +

+ user {cpuUserSec.toFixed(1)}s / sys {cpuSystemSec.toFixed(1)}s +

+ ) : null} +
+
+ + {/* Node Version */} + + + + + Node + + + + + {health ? health.system.nodeVersion : "—"} + + + +
+ ); +} diff --git a/web/src/components/dashboard/health-section.tsx b/web/src/components/dashboard/health-section.tsx new file mode 100644 index 00000000..faaf7b7a --- /dev/null +++ b/web/src/components/dashboard/health-section.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { HealthCards } from "./health-cards"; +import { RestartHistory } from "./restart-history"; +import { isBotHealth, type BotHealth } from "./types"; + +const AUTO_REFRESH_MS = 60_000; + +function formatLastUpdated(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(date); +} + +export function HealthSection() { + const router = useRouter(); + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const abortControllerRef = useRef(null); + + const fetchHealth = useCallback(async (backgroundRefresh = false) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const didSetLoading = !backgroundRefresh; + + if (!backgroundRefresh) { + setLoading(true); + setError(null); + } + + try { + const response = await fetch("/api/bot-health", { + cache: "no-store", + signal: controller.signal, + }); + + if (response.status === 401) { + router.replace("/login"); + return; + } + + let payload: unknown = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const message = + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof payload.error === "string" + ? payload.error + : "Failed to fetch health data"; + throw new Error(message); + } + + if (!isBotHealth(payload)) { + throw new Error("Invalid health payload from server"); + } + + setHealth(payload); + setError(null); + setLastUpdatedAt(new Date()); + } catch (fetchError) { + if (fetchError instanceof DOMException && fetchError.name === "AbortError") return; + setError( + fetchError instanceof Error ? fetchError.message : "Failed to fetch health data", + ); + } finally { + if (didSetLoading) { + setLoading(false); + } + } + }, [router]); + + // Initial fetch + useEffect(() => { + void fetchHealth(); + return () => abortControllerRef.current?.abort(); + }, [fetchHealth]); + + // Auto-refresh every 60s + useEffect(() => { + const intervalId = window.setInterval(() => { + void fetchHealth(true); + }, AUTO_REFRESH_MS); + return () => window.clearInterval(intervalId); + }, [fetchHealth]); + + return ( +
+
+
+

Bot Health

+

+ Live metrics and restart history. Auto-refreshes every 60s. +

+ {lastUpdatedAt ? ( +

+ Last updated {formatLastUpdated(lastUpdatedAt)} +

+ ) : null} +
+ + +
+ + {error ? ( +
+ Failed to load health data: {error} + +
+ ) : null} + + + +
+ ); +} diff --git a/web/src/components/dashboard/log-filters.tsx b/web/src/components/dashboard/log-filters.tsx new file mode 100644 index 00000000..b9a17a79 --- /dev/null +++ b/web/src/components/dashboard/log-filters.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { LogFilter, LogLevel } from "@/lib/log-ws"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const LEVEL_OPTIONS: Array<{ value: LogFilter["level"]; label: string }> = [ + { value: "all", label: "All levels" }, + { value: "error", label: "🔴 Error" }, + { value: "warn", label: "🟡 Warn" }, + { value: "info", label: "🔵 Info" }, + { value: "debug", label: "⚫ Debug" }, +]; + +const DEBOUNCE_MS = 300; + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface LogFiltersProps { + onFilterChange: (filter: LogFilter) => void; + disabled?: boolean; +} + +/** + * Filter bar for the log viewer. + * + * Provides level dropdown, module input, and free-text search. + * Debounces text inputs and sends consolidated filter to WS server. + */ +export function LogFilters({ onFilterChange, disabled = false }: LogFiltersProps) { + const [level, setLevel] = useState("all"); + const [module, setModule] = useState(""); + const [search, setSearch] = useState(""); + + const debounceRef = useRef | null>(null); + const onFilterChangeRef = useRef(onFilterChange); + onFilterChangeRef.current = onFilterChange; + + // Build and emit filter, debouncing text fields + const emitFilter = useCallback( + (opts: { level: LogFilter["level"]; module: string; search: string }) => { + const filter: LogFilter = {}; + if (opts.level && opts.level !== "all") filter.level = opts.level as LogLevel; + if (opts.module.trim()) filter.module = opts.module.trim(); + if (opts.search.trim()) filter.search = opts.search.trim(); + onFilterChangeRef.current(filter); + }, + [], + ); + + const scheduleEmit = useCallback( + (opts: { level: LogFilter["level"]; module: string; search: string }) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => emitFilter(opts), DEBOUNCE_MS); + }, + [emitFilter], + ); + + // Level change is instant (no debounce) + const handleLevelChange = useCallback( + (e: React.ChangeEvent) => { + const newLevel = e.target.value as LogFilter["level"]; + setLevel(newLevel); + // Cancel any pending debounce and emit immediately + if (debounceRef.current) clearTimeout(debounceRef.current); + emitFilter({ level: newLevel, module, search }); + }, + [emitFilter, module, search], + ); + + const handleModuleChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + setModule(val); + scheduleEmit({ level, module: val, search }); + }, + [level, search, scheduleEmit], + ); + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + setSearch(val); + scheduleEmit({ level, module, search: val }); + }, + [level, module, scheduleEmit], + ); + + const handleClear = useCallback(() => { + setLevel("all"); + setModule(""); + setSearch(""); + if (debounceRef.current) clearTimeout(debounceRef.current); + emitFilter({ level: "all", module: "", search: "" }); + }, [emitFilter]); + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const inputCls = + "h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; + + return ( +
+ {/* Level selector */} + + + {/* Module filter */} + + + {/* Search */} + + + {/* Clear */} + +
+ ); +} diff --git a/web/src/components/dashboard/log-viewer.tsx b/web/src/components/dashboard/log-viewer.tsx new file mode 100644 index 00000000..fe524db5 --- /dev/null +++ b/web/src/components/dashboard/log-viewer.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ConnectionStatus, LogEntry, LogLevel } from "@/lib/log-ws"; + +// ─── Level styling ──────────────────────────────────────────────────────────── + +const LEVEL_STYLES: Record = { + error: { + badge: "text-red-400 font-bold", + row: "hover:bg-red-950/30", + label: "ERR ", + }, + warn: { + badge: "text-yellow-400 font-bold", + row: "hover:bg-yellow-950/30", + label: "WARN", + }, + info: { + badge: "text-blue-400", + row: "hover:bg-blue-950/20", + label: "INFO", + }, + debug: { + badge: "text-gray-500", + row: "hover:bg-gray-800/30", + label: "DBUG", + }, +}; + +const STATUS_STYLES: Record = { + connected: { dot: "bg-green-500", label: "Connected" }, + disconnected: { dot: "bg-red-500", label: "Disconnected" }, + reconnecting: { dot: "bg-yellow-500 animate-pulse", label: "Reconnecting…" }, +}; + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatusIndicator({ status }: { status: ConnectionStatus }) { + const s = STATUS_STYLES[status]; + return ( +
+ + {s.label} +
+ ); +} + +function LogRow({ + entry, + isExpanded, + onToggle, +}: { + entry: LogEntry; + isExpanded: boolean; + onToggle: () => void; +}) { + const level = LEVEL_STYLES[entry.level]; + const time = new Date(entry.timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const hasMeta = entry.meta && Object.keys(entry.meta).length > 0; + + const handleKeyDown = hasMeta + ? (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + } + : undefined; + + return ( +
+ {/* Main row */} +
+ {/* Timestamp */} + {time} + + {/* Level badge */} + {level.label} + + {/* Module */} + {entry.module && ( + + [{entry.module}] + + )} + + {/* Message */} + {entry.message} + + {/* Expand indicator */} + {hasMeta && ( + + {isExpanded ? "▲" : "▼"} + + )} +
+ + {/* Expanded metadata */} + {isExpanded && hasMeta && ( +
+
+            {JSON.stringify(entry.meta, null, 2)}
+          
+
+ )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface LogViewerProps { + logs: LogEntry[]; + status: ConnectionStatus; + onClear: () => void; +} + +/** + * Terminal-style log display with auto-scroll, pause, and metadata expansion. + * + * Renders up to 1000 log entries (enforced by the hook). Uses JetBrains Mono + * for that authentic terminal vibe. + */ +export function LogViewer({ logs, status, onClear }: LogViewerProps) { + const [paused, setPaused] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const containerRef = useRef(null); + const bottomRef = useRef(null); + const userScrolledRef = useRef(false); + + // Auto-scroll to bottom when new logs arrive (unless paused/user scrolled) + useEffect(() => { + if (paused || userScrolledRef.current) return; + bottomRef.current?.scrollIntoView({ behavior: "instant" }); + }, [logs, paused]); + + // Detect manual scroll to pause auto-scroll + const handleScroll = useCallback(() => { + const el = containerRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + userScrolledRef.current = distanceFromBottom > 50; + }, []); + + const togglePause = useCallback(() => { + setPaused((p) => { + const next = !p; + if (!next) { + // Resume — scroll to bottom + userScrolledRef.current = false; + setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 50); + } + return next; + }); + }, []); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + return ( +
+ {/* Toolbar */} +
+ +
+ {logs.length} entries + + +
+
+ + {/* Log list */} +
+ {logs.length === 0 ? ( +
+ {status === "connected" + ? "Waiting for logs…" + : status === "reconnecting" + ? "Connecting to log stream…" + : "Not connected"} +
+ ) : ( + logs.map((entry) => ( + toggleExpand(entry.id)} + /> + )) + )} +
+
+
+ ); +} diff --git a/web/src/components/dashboard/restart-history.tsx b/web/src/components/dashboard/restart-history.tsx new file mode 100644 index 00000000..711295b9 --- /dev/null +++ b/web/src/components/dashboard/restart-history.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { BotHealth, RestartRecord } from "./types"; +import { formatUptime } from "@/lib/format-time"; + +interface RestartHistoryProps { + health: BotHealth | null; + loading: boolean; +} + +const MAX_RESTARTS = 20; + +function formatTimestamp(iso: string): string { + try { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(new Date(iso)); + } catch { + return iso; + } +} + +type ReasonStyle = { + bg: string; + text: string; + label: string; +}; + +function reasonStyle(reason: string): ReasonStyle { + const normalized = reason.toLowerCase(); + + // Check crash/restart before startup to avoid "restart" matching "start" + if ( + normalized.includes("crash") || + normalized.includes("error") || + normalized.includes("uncaught") || + normalized.includes("unhandled") + ) { + return { bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-700 dark:text-red-400", label: reason }; + } + if (normalized.includes("restart")) { + return { bg: "bg-yellow-100 dark:bg-yellow-900/30", text: "text-yellow-700 dark:text-yellow-400", label: reason }; + } + if (normalized.includes("startup") || normalized.startsWith("start")) { + return { bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-700 dark:text-green-400", label: reason }; + } + if (normalized.includes("deploy") || normalized.includes("update")) { + return { bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-400", label: reason }; + } + if (normalized.includes("shutdown") || normalized.includes("sigterm") || normalized.includes("sigint")) { + return { bg: "bg-yellow-100 dark:bg-yellow-900/30", text: "text-yellow-700 dark:text-yellow-400", label: reason }; + } + + return { bg: "bg-muted", text: "text-muted-foreground", label: reason }; +} + +function ReasonBadge({ reason }: { reason: string }) { + const style = reasonStyle(reason); + return ( + + {style.label} + + ); +} + +function TableSkeleton() { + return ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ ); +} + +export function RestartHistory({ health, loading }: RestartHistoryProps) { + const restarts: RestartRecord[] = health + ? [...health.restarts] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, MAX_RESTARTS) + : []; + + return ( + + + Restart History + Last {MAX_RESTARTS} restarts, most recent first. + + + {loading && !health ? ( + + ) : restarts.length === 0 ? ( +

+ {health ? "No restarts recorded." : "No data available."} +

+ ) : ( +
+ + + + + + + + + + + {restarts.map((restart, i) => ( + + + + + + + ))} + +
TimestampReasonVersionUptime Before
+ {formatTimestamp(restart.timestamp)} + + + + {restart.version ?? "—"} + + {restart.uptimeBefore != null ? formatUptime(restart.uptimeBefore) : "—"} +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/dashboard/types.ts b/web/src/components/dashboard/types.ts new file mode 100644 index 00000000..a8b243c9 --- /dev/null +++ b/web/src/components/dashboard/types.ts @@ -0,0 +1,78 @@ +/** Shape of a single restart record from the bot health endpoint. */ +export interface RestartRecord { + timestamp: string; + reason: string; + version: string | null; + uptimeBefore: number | null; // seconds +} + +/** Shape of the bot health payload from GET /api/v1/health. */ +export interface BotHealth { + uptime: number; // seconds + memory: { + heapUsed: number; // bytes + heapTotal: number; // bytes + rss?: number; // bytes + }; + discord: { + ping: number; // ms + guilds: number; + }; + errors: { + lastHour: number | null; + lastDay: number | null; + }; + system: { + cpuUsage: { + user: number; // microseconds + system: number; // microseconds + }; + nodeVersion: string; + }; + restarts: RestartRecord[]; +} + +export function isBotHealth(value: unknown): value is BotHealth { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + + if (typeof v.uptime !== "number") return false; + + const mem = v.memory; + if (typeof mem !== "object" || mem === null) return false; + const m = mem as Record; + if (typeof m.heapUsed !== "number" || typeof m.heapTotal !== "number") return false; + if (m.rss !== undefined && typeof m.rss !== "number") return false; + + const discord = v.discord; + if (typeof discord !== "object" || discord === null) return false; + const d = discord as Record; + if (typeof d.ping !== "number" || typeof d.guilds !== "number") return false; + + const errors = v.errors; + if (typeof errors !== "object" || errors === null) return false; + const e = errors as Record; + if (e.lastHour !== null && typeof e.lastHour !== "number") return false; + if (e.lastDay !== null && typeof e.lastDay !== "number") return false; + + const system = v.system; + if (typeof system !== "object" || system === null) return false; + const s = system as Record; + if (typeof s.nodeVersion !== "string") return false; + const cpu = s.cpuUsage; + if (typeof cpu !== "object" || cpu === null) return false; + const c = cpu as Record; + if (typeof c.user !== "number" || typeof c.system !== "number") return false; + + if (!Array.isArray(v.restarts)) return false; + for (const item of v.restarts) { + if (typeof item !== "object" || item === null) return false; + const r = item as Record; + if (typeof r.timestamp !== "string") return false; + if (typeof r.reason !== "string") return false; + if (r.version !== null && typeof r.version !== "string") return false; + if (r.uptimeBefore !== null && typeof r.uptimeBefore !== "number") return false; + } + + return true; +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 886ea52f..f397b01d 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { MessageSquare, Users, Bot, + ScrollText, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; @@ -39,6 +40,11 @@ const navigation = [ href: "/dashboard/config", icon: Bot, }, + { + name: "Logs", + href: "/dashboard/logs", + icon: ScrollText, + }, { name: "Settings", href: "/dashboard/settings", diff --git a/web/src/lib/format-time.ts b/web/src/lib/format-time.ts new file mode 100644 index 00000000..0266580d --- /dev/null +++ b/web/src/lib/format-time.ts @@ -0,0 +1,17 @@ +/** + * Format seconds into a human-readable duration string. + * Returns "Xs" for durations under one minute, otherwise "Xd Xh Xm". + */ +export function formatUptime(seconds: number): string { + if (seconds < 60) return `${Math.floor(seconds)}s`; + const d = Math.floor(seconds / 86_400); + const h = Math.floor((seconds % 86_400) / 3_600); + const m = Math.floor((seconds % 3_600) / 60); + + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0) parts.push(`${h}h`); + if (m > 0 || parts.length === 0) parts.push(`${m}m`); + + return parts.join(" "); +} diff --git a/web/src/lib/log-ws.ts b/web/src/lib/log-ws.ts new file mode 100644 index 00000000..4b1b6609 --- /dev/null +++ b/web/src/lib/log-ws.ts @@ -0,0 +1,266 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +export interface LogEntry { + /** Unique client-side ID (timestamp + index) */ + id: string; + timestamp: string; + level: LogLevel; + message: string; + module?: string; + /** Arbitrary structured metadata */ + meta?: Record; +} + +export interface LogFilter { + level?: LogLevel | "all"; + module?: string; + search?: string; +} + +export type ConnectionStatus = "connected" | "disconnected" | "reconnecting"; + +export interface UseLogStreamResult { + logs: LogEntry[]; + status: ConnectionStatus; + sendFilter: (filter: LogFilter) => void; + clearLogs: () => void; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const MAX_LOGS = 1000; +const INITIAL_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 30_000; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +let _idSeq = 0; +function makeId(): string { + return `${Date.now()}-${_idSeq++}`; +} + +function normalizeLevel(raw: unknown): LogLevel { + const s = String(raw ?? "info").toLowerCase(); + if (s === "error" || s === "warn" || s === "info" || s === "debug") return s; + return "info"; +} + +function normalizeEntry(raw: unknown, id: string): LogEntry | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + + const message = typeof r.message === "string" ? r.message : JSON.stringify(r.message ?? ""); + const timestamp = + typeof r.timestamp === "string" + ? r.timestamp + : new Date().toISOString(); + const level = normalizeLevel(r.level); + const module = typeof r.module === "string" ? r.module : undefined; + + // Flatten server `metadata` object into meta alongside other extra fields + const { message: _m, timestamp: _t, level: _l, module: _mod, type: _type, metadata: rawMeta, ...rest } = r; + const flatMeta: Record = { + ...(typeof rawMeta === "object" && rawMeta !== null && !Array.isArray(rawMeta) + ? (rawMeta as Record) + : {}), + ...rest, + }; + const meta = Object.keys(flatMeta).length > 0 ? flatMeta : undefined; + + return { id, timestamp, level, message, module, meta }; +} + +// ─── Hook ──────────────────────────────────────────────────────────────────── + +/** + * Connect to the bot's /ws/logs endpoint. + * + * Fetches WS URL + auth secret from the Next.js API route first, then + * maintains a WebSocket connection with auto-reconnect (exponential backoff). + * + * @param enabled - Set to false to disable connection (e.g. when page is hidden) + */ +export function useLogStream(enabled = true): UseLogStreamResult { + const [logs, setLogs] = useState([]); + const [status, setStatus] = useState("disconnected"); + + const wsRef = useRef(null); + const backoffRef = useRef(INITIAL_BACKOFF_MS); + const reconnectTimerRef = useRef | null>(null); + const activeFilterRef = useRef({}); + const ticketRef = useRef<{ wsUrl: string; ticket: string } | null>(null); + const unmountedRef = useRef(false); + const connectingRef = useRef(false); + const connectAttemptRef = useRef(0); + + // ── Fetch ticket once ────────────────────────────────────────────────────── + const fetchTicket = useCallback(async (): Promise<{ wsUrl: string; ticket: string } | null> => { + // Always fetch a fresh ticket — they're short-lived HMAC tokens + try { + const res = await fetch("/api/log-stream/ws-ticket", { cache: "no-store" }); + if (!res.ok) return null; + const data = (await res.json()) as { wsUrl?: string; ticket?: string }; + if (!data.wsUrl || !data.ticket) return null; + ticketRef.current = { wsUrl: data.wsUrl, ticket: data.ticket }; + return ticketRef.current; + } catch { + return null; + } + }, []); + + // ── Connect ──────────────────────────────────────────────────────────────── + const connect = useCallback(async () => { + if (unmountedRef.current || connectingRef.current) return; + connectingRef.current = true; + const attempt = ++connectAttemptRef.current; + + const ticket = await fetchTicket(); + + // Bail if a newer connect() has superseded us or component unmounted + if (attempt !== connectAttemptRef.current || unmountedRef.current) { + connectingRef.current = false; + return; + } + + if (!ticket) { + connectingRef.current = false; + // Ticket fetch failed — retry with backoff instead of giving up + if (!unmountedRef.current) { + setStatus("reconnecting"); + const delay = backoffRef.current; + backoffRef.current = Math.min(backoffRef.current * 2, MAX_BACKOFF_MS); + reconnectTimerRef.current = setTimeout(() => { + if (!unmountedRef.current) connect(); + }, delay); + } + return; + } + + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + wsRef.current = null; + } + + const ws = new WebSocket(ticket.wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (unmountedRef.current) { + ws.close(); + return; + } + ws.send(JSON.stringify({ type: "auth", ticket: ticket.ticket })); + }; + + ws.onmessage = (event: MessageEvent) => { + if (unmountedRef.current) return; + let msg: unknown; + try { + msg = JSON.parse(event.data as string); + } catch { + return; + } + + if (typeof msg !== "object" || msg === null) return; + const m = msg as Record; + + switch (m.type) { + case "auth_ok": { + setStatus("connected"); + backoffRef.current = INITIAL_BACKOFF_MS; + connectingRef.current = false; + // Re-apply active filter after reconnect + const f = activeFilterRef.current; + if (Object.keys(f).length > 0) { + ws.send(JSON.stringify({ type: "filter", ...f })); + } + break; + } + + case "history": { + const entries = Array.isArray(m.logs) ? m.logs : []; + const normalized = entries + .map((e: unknown) => normalizeEntry(e, makeId())) + .filter((e): e is LogEntry => e !== null) + .slice(-MAX_LOGS); + setLogs(normalized); + break; + } + + case "log": { + const entry = normalizeEntry(m, makeId()); + if (!entry) return; + setLogs((prev) => { + const next = [...prev, entry]; + return next.length > MAX_LOGS ? next.slice(next.length - MAX_LOGS) : next; + }); + break; + } + + default: + break; + } + }; + + ws.onerror = () => { + // Will be followed by onclose — handle there + }; + + ws.onclose = () => { + if (unmountedRef.current || attempt !== connectAttemptRef.current) return; + connectingRef.current = false; + setStatus("reconnecting"); + + const delay = backoffRef.current; + backoffRef.current = Math.min(backoffRef.current * 2, MAX_BACKOFF_MS); + + reconnectTimerRef.current = setTimeout(() => { + if (!unmountedRef.current) connect(); + }, delay); + }; + }, [fetchTicket]); + + // ── Lifecycle ────────────────────────────────────────────────────────────── + useEffect(() => { + unmountedRef.current = false; + + if (enabled) { + setStatus("reconnecting"); + connect(); + } + + return () => { + unmountedRef.current = true; + connectingRef.current = false; + connectAttemptRef.current++; // Invalidate any in-flight connect + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + wsRef.current = null; + } + setStatus("disconnected"); + }; + }, [enabled, connect]); + + // ── Actions ──────────────────────────────────────────────────────────────── + const sendFilter = useCallback((filter: LogFilter) => { + activeFilterRef.current = filter; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "filter", ...filter })); + } + }, []); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + return { logs, status, sendFilter, clearLogs }; +}