From 8bccce5300b9a6c20431053276338c11fa7f0407 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:37:53 -0500 Subject: [PATCH 1/4] feat: add performance monitoring module and API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PerformanceMonitor singleton with circular buffer time-series storage - Samples memory (heap/RSS) and CPU every 30s - Records command and API response times - Configurable alert thresholds with callback hooks (5min cooldown) - GET /api/v1/performance — full snapshot (auth required) - GET/PUT /api/v1/performance/thresholds — threshold management - Wired into bot startup/shutdown lifecycle - API response time middleware on all /api/v1 routes - Command execution timing in slash command handler --- src/api/index.js | 5 + src/api/routes/performance.js | 122 ++++++++++++++ src/api/server.js | 12 ++ src/index.js | 10 ++ src/modules/performanceMonitor.js | 272 ++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 src/api/routes/performance.js create mode 100644 src/modules/performanceMonitor.js diff --git a/src/api/index.js b/src/api/index.js index 0a69c678d..fb87232bf 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -18,6 +18,7 @@ import membersRouter from './routes/members.js'; import moderationRouter from './routes/moderation.js'; import ticketsRouter from './routes/tickets.js'; import webhooksRouter from './routes/webhooks.js'; +import performanceRouter from './routes/performance.js'; const router = Router(); @@ -58,7 +59,11 @@ router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter) // GET-only; no audit middleware needed (reads are not mutating actions) router.use('/guilds', requireAuth(), auditLogRouter); +// Performance metrics — require x-api-secret (authenticated via route handler) +router.use('/performance', performanceRouter); + // Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret) router.use('/webhooks', requireAuth(), webhooksRouter); export default router; + diff --git a/src/api/routes/performance.js b/src/api/routes/performance.js new file mode 100644 index 000000000..82a393763 --- /dev/null +++ b/src/api/routes/performance.js @@ -0,0 +1,122 @@ +/** + * Performance Metrics Route + * + * Returns bot performance metrics including memory usage, CPU utilization, + * response time statistics, and configurable alert thresholds. + * + * All endpoints require x-api-secret authentication. + */ + +import { Router } from 'express'; +import { PerformanceMonitor } from '../../modules/performanceMonitor.js'; +import { isValidSecret } from '../middleware/auth.js'; + +const router = Router(); + +/** + * @openapi + * /performance: + * get: + * tags: + * - Performance + * summary: Get performance metrics snapshot + * description: > + * Returns the full performance snapshot including current stats, + * time-series data for memory/CPU, response time samples and summary, + * and configured alert thresholds. Requires x-api-secret header. + * parameters: + * - in: header + * name: x-api-secret + * schema: + * type: string + * required: true + * responses: + * "200": + * description: Performance snapshot + * "401": + * description: Unauthorized + */ +router.get('/', (req, res) => { + if (!isValidSecret(req.headers['x-api-secret'])) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const monitor = PerformanceMonitor.getInstance(); + const snapshot = monitor.getSnapshot(); + res.json(snapshot); +}); + +/** + * @openapi + * /performance/thresholds: + * get: + * tags: + * - Performance + * summary: Get alert thresholds + * security: + * - apiSecret: [] + * responses: + * "200": + * description: Current thresholds + * put: + * tags: + * - Performance + * summary: Update alert thresholds + * description: Partial update — only supplied fields are changed. + * security: + * - apiSecret: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * memoryHeapMb: + * type: number + * memoryRssMb: + * type: number + * cpuPercent: + * type: number + * responseTimeMs: + * type: number + * responses: + * "200": + * description: Updated thresholds + */ +router.get('/thresholds', (req, res) => { + if (!isValidSecret(req.headers['x-api-secret'])) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const monitor = PerformanceMonitor.getInstance(); + res.json(monitor.getThresholds()); +}); + +router.put('/thresholds', (req, res) => { + if (!isValidSecret(req.headers['x-api-secret'])) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const allowed = new Set(['memoryHeapMb', 'memoryRssMb', 'cpuPercent', 'responseTimeMs']); + const update = {}; + + for (const [key, val] of Object.entries(req.body ?? {})) { + if (!allowed.has(key)) continue; + const num = Number(val); + if (!Number.isFinite(num) || num <= 0) { + return res.status(400).json({ error: `Invalid value for ${key}: must be a positive number` }); + } + update[key] = num; + } + + if (Object.keys(update).length === 0) { + return res.status(400).json({ error: 'No valid threshold fields provided' }); + } + + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds(update); + res.json(monitor.getThresholds()); +}); + +export default router; diff --git a/src/api/server.js b/src/api/server.js index 39049a4b0..d8c3cbc74 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -6,6 +6,7 @@ import express from 'express'; import { error, info, warn } from '../logger.js'; import apiRouter from './index.js'; +import { PerformanceMonitor } from '../modules/performanceMonitor.js'; import { rateLimit } from './middleware/rateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; import { swaggerSpec } from './swagger.js'; @@ -68,6 +69,17 @@ export function createApp(client, dbPool) { // Raw OpenAPI spec (JSON) — public for Mintlify app.get('/api/docs.json', (_req, res) => res.json(swaggerSpec)); + // Response time tracking for performance monitoring + app.use('/api/v1', (req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + const label = `${req.method} ${req.path}`; + PerformanceMonitor.getInstance().recordResponseTime(label, duration, 'api'); + }); + next(); + }); + // Mount API routes under /api/v1 app.use('/api/v1', apiRouter); diff --git a/src/index.js b/src/index.js index 22197c30c..a0960393b 100644 --- a/src/index.js +++ b/src/index.js @@ -55,6 +55,7 @@ import { closeRedisClient as closeRedis, initRedis } from './redis.js'; import { pruneOldLogs } from './transports/postgres.js'; import { stopCacheCleanup } from './utils/cache.js'; import { HealthMonitor } from './utils/health.js'; +import { PerformanceMonitor } from './modules/performanceMonitor.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; @@ -119,6 +120,9 @@ client.commands = new Collection(); // Initialize health monitor const healthMonitor = HealthMonitor.getInstance(); +// Initialize performance monitor (singleton; start() called in ClientReady handler) +const perfMonitor = PerformanceMonitor.getInstance(); + /** * Save conversation history to disk */ @@ -232,7 +236,9 @@ client.on('interactionCreate', async (interaction) => { return; } + const _cmdStart = Date.now(); await command.execute(interaction); + perfMonitor.recordResponseTime(commandName, Date.now() - _cmdStart, 'command'); info('Command executed', { command: commandName, user: interaction.user.tag, @@ -277,6 +283,7 @@ async function gracefulShutdown(signal) { stopTempbanScheduler(); stopScheduler(); stopGithubFeed(); + perfMonitor.stop(); // 1.5. Stop API server (drain in-flight HTTP requests before closing DB) try { @@ -459,6 +466,9 @@ async function startup() { // Register event handlers with live config reference registerEventHandlers(client, config, healthMonitor); + // Start performance monitor + perfMonitor.start(); + // Start triage module (per-channel message classification + response) await startTriage(client, config, healthMonitor); diff --git a/src/modules/performanceMonitor.js b/src/modules/performanceMonitor.js new file mode 100644 index 000000000..79d243085 --- /dev/null +++ b/src/modules/performanceMonitor.js @@ -0,0 +1,272 @@ +/** + * Performance Monitor + * + * Tracks bot performance metrics over time: + * - Memory usage (heap, RSS) — sampled every 30s + * - CPU utilization — sampled every 30s + * - Command/interaction response times + * - API request response times + * - Alert thresholds with configurable callbacks + * + * All time-series data is stored in circular buffers (in-memory). + * Data is NOT persisted to disk; it resets on bot restart. + */ + +import { info, warn } from '../logger.js'; + +/** How many data points to retain per metric (30s interval × 120 = 1hr) */ +const DEFAULT_BUFFER_SIZE = 120; + +/** How often to sample memory/CPU (ms) */ +const SAMPLE_INTERVAL_MS = 30_000; + +/** + * Default alert thresholds + */ +const DEFAULT_THRESHOLDS = { + memoryHeapMb: 512, + memoryRssMb: 768, + cpuPercent: 80, + responseTimeMs: 5_000, +}; + +/** + * Circular buffer — fixed capacity, overwrites oldest on full + */ +class CircularBuffer { + constructor(capacity) { + this._capacity = capacity; + this._buf = new Array(capacity); + this._head = 0; + this._size = 0; + } + + push(item) { + this._buf[this._head] = item; + this._head = (this._head + 1) % this._capacity; + if (this._size < this._capacity) this._size++; + } + + /** Returns oldest → newest */ + toArray() { + if (this._size === 0) return []; + if (this._size < this._capacity) { + return this._buf.slice(0, this._size); + } + return [...this._buf.slice(this._head), ...this._buf.slice(0, this._head)]; + } + + get size() { + return this._size; + } + + clear() { + this._head = 0; + this._size = 0; + } +} + +/** + * PerformanceMonitor singleton. + */ +class PerformanceMonitor { + constructor() { + if (PerformanceMonitor.instance) { + throw new Error('Use PerformanceMonitor.getInstance()'); + } + + this._memHeap = new CircularBuffer(DEFAULT_BUFFER_SIZE); + this._memRss = new CircularBuffer(DEFAULT_BUFFER_SIZE); + this._cpu = new CircularBuffer(DEFAULT_BUFFER_SIZE); + this._responseTimes = new CircularBuffer(DEFAULT_BUFFER_SIZE * 4); + this._thresholds = { ...DEFAULT_THRESHOLDS }; + this._alertCallbacks = []; + this._timer = null; + this._prevCpuUsage = null; + this._prevCpuTime = null; + this._lastAlert = {}; + + PerformanceMonitor.instance = this; + } + + static getInstance() { + if (!PerformanceMonitor.instance) { + PerformanceMonitor.instance = new PerformanceMonitor(); + } + return PerformanceMonitor.instance; + } + + /** Start the periodic sampler. Idempotent. */ + start() { + if (this._timer) return; + + this._prevCpuUsage = process.cpuUsage(); + this._prevCpuTime = Date.now(); + + this._timer = setInterval(() => { + this._sample(); + }, SAMPLE_INTERVAL_MS); + + if (this._timer.unref) this._timer.unref(); + + info('PerformanceMonitor started', { intervalMs: SAMPLE_INTERVAL_MS }); + } + + /** Stop the periodic sampler. */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + info('PerformanceMonitor stopped'); + } + } + + /** + * Update alert thresholds at runtime. + * @param {object} thresholds + */ + setThresholds(thresholds) { + this._thresholds = { ...this._thresholds, ...thresholds }; + info('PerformanceMonitor thresholds updated', this._thresholds); + } + + getThresholds() { + return { ...this._thresholds }; + } + + /** + * Register an alert callback. + * Called when a metric exceeds its threshold (5-minute cooldown per metric). + * @param {Function} fn - (metric, value, threshold, label) => void + */ + onAlert(fn) { + this._alertCallbacks.push(fn); + } + + /** + * Record a response time sample. + * @param {string} name - Command or endpoint label + * @param {number} durationMs + * @param {'command'|'api'} type + */ + recordResponseTime(name, durationMs, type = 'command') { + this._responseTimes.push({ timestamp: Date.now(), name, durationMs, type }); + this._checkThreshold('responseTimeMs', durationMs, this._thresholds.responseTimeMs, name); + } + + /** + * Get a full performance snapshot. + */ + getSnapshot() { + const mem = process.memoryUsage(); + return { + current: { + memoryHeapMb: Math.round(mem.heapUsed / 1024 / 1024), + memoryRssMb: Math.round(mem.rss / 1024 / 1024), + memoryHeapTotalMb: Math.round(mem.heapTotal / 1024 / 1024), + memoryExternalMb: Math.round(mem.external / 1024 / 1024), + cpuPercent: this._getLastCpuPercent(), + uptime: process.uptime(), + }, + thresholds: this.getThresholds(), + timeSeries: { + memoryHeapMb: this._memHeap.toArray(), + memoryRssMb: this._memRss.toArray(), + cpuPercent: this._cpu.toArray(), + }, + responseTimes: this._responseTimes.toArray(), + summary: this._buildSummary(), + }; + } + + // ─── Private ─────────────────────────────────────────────── + + _sample() { + const ts = Date.now(); + const mem = process.memoryUsage(); + const heapMb = Math.round(mem.heapUsed / 1024 / 1024); + const rssMb = Math.round(mem.rss / 1024 / 1024); + const cpuPct = this._sampleCpu(); + + this._memHeap.push({ timestamp: ts, value: heapMb }); + this._memRss.push({ timestamp: ts, value: rssMb }); + this._cpu.push({ timestamp: ts, value: cpuPct }); + + this._checkThreshold('memoryHeapMb', heapMb, this._thresholds.memoryHeapMb); + this._checkThreshold('memoryRssMb', rssMb, this._thresholds.memoryRssMb); + this._checkThreshold('cpuPercent', cpuPct, this._thresholds.cpuPercent); + } + + /** Calculate CPU utilization % since last sample. */ + _sampleCpu() { + const now = Date.now(); + const currentCpu = process.cpuUsage(); + + if (!this._prevCpuUsage || !this._prevCpuTime) { + this._prevCpuUsage = currentCpu; + this._prevCpuTime = now; + return 0; + } + + const elapsedMs = now - this._prevCpuTime; + const userDelta = currentCpu.user - this._prevCpuUsage.user; + const systemDelta = currentCpu.system - this._prevCpuUsage.system; + const cpuDeltaMs = (userDelta + systemDelta) / 1000; + const pct = Math.min(100, Math.round((cpuDeltaMs / elapsedMs) * 100)); + + this._prevCpuUsage = currentCpu; + this._prevCpuTime = now; + + return pct; + } + + _getLastCpuPercent() { + const arr = this._cpu.toArray(); + return arr.length > 0 ? arr[arr.length - 1].value : 0; + } + + _checkThreshold(metric, value, threshold, label = '') { + if (value <= threshold) return; + + const cooldownMs = 5 * 60 * 1000; + const key = label ? `${metric}:${label}` : metric; + const lastTs = this._lastAlert[key] || 0; + if (Date.now() - lastTs < cooldownMs) return; + + this._lastAlert[key] = Date.now(); + const fullLabel = label ? `${metric} [${label}]` : metric; + warn(`Performance alert: ${fullLabel} exceeded threshold`, { value, threshold, metric, label }); + + for (const cb of this._alertCallbacks) { + try { + cb(metric, value, threshold, label); + } catch (err) { + warn('PerformanceMonitor alert callback threw', { err: err?.message }); + } + } + } + + _buildSummary() { + const samples = this._responseTimes.toArray(); + if (samples.length === 0) { + return { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 }; + } + + const durations = samples.map((s) => s.durationMs).sort((a, b) => a - b); + const len = durations.length; + const avg = Math.round(durations.reduce((a, b) => a + b, 0) / len); + + return { + count: len, + avgMs: avg, + p50Ms: durations[Math.floor(len * 0.5)] ?? 0, + p95Ms: durations[Math.floor(len * 0.95)] ?? 0, + p99Ms: durations[Math.floor(len * 0.99)] ?? 0, + maxMs: durations[len - 1] ?? 0, + }; + } +} + +PerformanceMonitor.instance = null; + +export { PerformanceMonitor, CircularBuffer, DEFAULT_THRESHOLDS, SAMPLE_INTERVAL_MS }; From 3112bc11e0f77afc1208ebcc49d3e29f3792ca9e Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:39:12 -0500 Subject: [PATCH 2/4] test: add performance monitor and API route tests (36 tests) --- tests/api/routes/performance.test.js | 193 ++++++++++++++++ tests/modules/performanceMonitor.test.js | 281 +++++++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 tests/api/routes/performance.test.js create mode 100644 tests/modules/performanceMonitor.test.js diff --git a/tests/api/routes/performance.test.js b/tests/api/routes/performance.test.js new file mode 100644 index 000000000..f7e676bcb --- /dev/null +++ b/tests/api/routes/performance.test.js @@ -0,0 +1,193 @@ +/** + * Tests for src/api/routes/performance.js + */ + +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../../src/utils/logQuery.js', () => ({ + queryLogs: vi.fn().mockResolvedValue({ rows: [], total: 0 }), +})); + +vi.mock('../../../src/utils/restartTracker.js', () => { + throw new Error('Module not found'); +}); + +import { createApp } from '../../../src/api/server.js'; +import { PerformanceMonitor } from '../../../src/modules/performanceMonitor.js'; + +const TEST_SECRET = 'perf-test-secret'; + +function buildApp() { + const client = { + guilds: { cache: new Map([['guild1', {}]]) }, + ws: { status: 0, ping: 10 }, + user: { tag: 'TestBot#0001' }, + }; + return createApp(client, null); +} + +describe('GET /api/v1/performance', () => { + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + // Reset singleton so each test gets a fresh instance + PerformanceMonitor.instance = null; + }); + + afterEach(() => { + if (PerformanceMonitor.instance) { + PerformanceMonitor.instance.stop(); + } + PerformanceMonitor.instance = null; + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('returns 401 without auth', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/performance'); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Unauthorized'); + }); + + it('returns 401 with wrong secret', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/performance').set('x-api-secret', 'wrong'); + expect(res.status).toBe(401); + }); + + it('returns snapshot with valid auth', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/performance').set('x-api-secret', TEST_SECRET); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('current'); + expect(res.body).toHaveProperty('thresholds'); + expect(res.body).toHaveProperty('timeSeries'); + expect(res.body).toHaveProperty('responseTimes'); + expect(res.body).toHaveProperty('summary'); + }); + + it('snapshot current includes expected fields', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/performance').set('x-api-secret', TEST_SECRET); + const { current } = res.body; + expect(current).toHaveProperty('memoryHeapMb'); + expect(current).toHaveProperty('memoryRssMb'); + expect(current).toHaveProperty('cpuPercent'); + expect(current).toHaveProperty('uptime'); + expect(typeof current.memoryHeapMb).toBe('number'); + expect(typeof current.uptime).toBe('number'); + }); + + it('snapshot includes response times recorded via monitor', async () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.recordResponseTime('ping', 42, 'command'); + + const app = buildApp(); + const res = await request(app).get('/api/v1/performance').set('x-api-secret', TEST_SECRET); + expect(res.body.responseTimes).toHaveLength(1); + expect(res.body.responseTimes[0].name).toBe('ping'); + }); +}); + +describe('GET /api/v1/performance/thresholds', () => { + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + PerformanceMonitor.instance = null; + }); + + afterEach(() => { + if (PerformanceMonitor.instance) { + PerformanceMonitor.instance.stop(); + } + PerformanceMonitor.instance = null; + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('returns 401 without auth', async () => { + const app = buildApp(); + const res = await request(app).get('/api/v1/performance/thresholds'); + expect(res.status).toBe(401); + }); + + it('returns current thresholds with valid auth', async () => { + const app = buildApp(); + const res = await request(app) + .get('/api/v1/performance/thresholds') + .set('x-api-secret', TEST_SECRET); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('memoryHeapMb'); + expect(res.body).toHaveProperty('memoryRssMb'); + expect(res.body).toHaveProperty('cpuPercent'); + expect(res.body).toHaveProperty('responseTimeMs'); + }); +}); + +describe('PUT /api/v1/performance/thresholds', () => { + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + PerformanceMonitor.instance = null; + }); + + afterEach(() => { + if (PerformanceMonitor.instance) { + PerformanceMonitor.instance.stop(); + } + PerformanceMonitor.instance = null; + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('returns 401 without auth', async () => { + const app = buildApp(); + const res = await request(app).put('/api/v1/performance/thresholds').send({ memoryHeapMb: 256 }); + expect(res.status).toBe(401); + }); + + it('updates thresholds and returns new values', async () => { + const app = buildApp(); + const res = await request(app) + .put('/api/v1/performance/thresholds') + .set('x-api-secret', TEST_SECRET) + .send({ memoryHeapMb: 256, cpuPercent: 90 }); + expect(res.status).toBe(200); + expect(res.body.memoryHeapMb).toBe(256); + expect(res.body.cpuPercent).toBe(90); + }); + + it('returns 400 when no valid fields provided', async () => { + const app = buildApp(); + const res = await request(app) + .put('/api/v1/performance/thresholds') + .set('x-api-secret', TEST_SECRET) + .send({ unknownField: 999 }); + expect(res.status).toBe(400); + }); + + it('returns 400 when value is not a positive number', async () => { + const app = buildApp(); + const res = await request(app) + .put('/api/v1/performance/thresholds') + .set('x-api-secret', TEST_SECRET) + .send({ memoryHeapMb: -100 }); + expect(res.status).toBe(400); + }); + + it('ignores unknown fields', async () => { + const app = buildApp(); + const res = await request(app) + .put('/api/v1/performance/thresholds') + .set('x-api-secret', TEST_SECRET) + .send({ memoryHeapMb: 512, hackerField: 'inject' }); + expect(res.status).toBe(200); + expect(res.body).not.toHaveProperty('hackerField'); + }); +}); diff --git a/tests/modules/performanceMonitor.test.js b/tests/modules/performanceMonitor.test.js new file mode 100644 index 000000000..6854ee2f0 --- /dev/null +++ b/tests/modules/performanceMonitor.test.js @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger to prevent file I/O during tests +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +describe('CircularBuffer', () => { + let CircularBuffer; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import('../../src/modules/performanceMonitor.js'); + CircularBuffer = mod.CircularBuffer; + }); + + it('returns empty array when empty', () => { + const buf = new CircularBuffer(5); + expect(buf.toArray()).toEqual([]); + expect(buf.size).toBe(0); + }); + + it('stores items in order when not full', () => { + const buf = new CircularBuffer(5); + buf.push(1); + buf.push(2); + buf.push(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + expect(buf.size).toBe(3); + }); + + it('overwrites oldest when full (circular)', () => { + const buf = new CircularBuffer(3); + buf.push('a'); + buf.push('b'); + buf.push('c'); + buf.push('d'); // overwrites 'a' + expect(buf.toArray()).toEqual(['b', 'c', 'd']); + expect(buf.size).toBe(3); + }); + + it('handles exactly-full buffer correctly', () => { + const buf = new CircularBuffer(3); + buf.push(1); + buf.push(2); + buf.push(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + it('clears the buffer', () => { + const buf = new CircularBuffer(5); + buf.push(1); + buf.push(2); + buf.clear(); + expect(buf.toArray()).toEqual([]); + expect(buf.size).toBe(0); + }); + + it('wraps around correctly after multiple overwrites', () => { + const buf = new CircularBuffer(3); + for (let i = 1; i <= 9; i++) buf.push(i); + // Last 3 pushed: 7, 8, 9 + expect(buf.toArray()).toEqual([7, 8, 9]); + }); +}); + +describe('PerformanceMonitor', () => { + let PerformanceMonitor; + let DEFAULT_THRESHOLDS; + + beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + const mod = await import('../../src/modules/performanceMonitor.js'); + PerformanceMonitor = mod.PerformanceMonitor; + DEFAULT_THRESHOLDS = mod.DEFAULT_THRESHOLDS; + // Reset singleton + PerformanceMonitor.instance = null; + }); + + afterEach(() => { + // Stop any running timer + if (PerformanceMonitor.instance) { + PerformanceMonitor.instance.stop(); + } + PerformanceMonitor.instance = null; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ── Singleton ────────────────────────────────────────────── + + it('returns same instance via getInstance', () => { + const a = PerformanceMonitor.getInstance(); + const b = PerformanceMonitor.getInstance(); + expect(a).toBe(b); + }); + + it('throws if constructor called after instance exists', () => { + PerformanceMonitor.getInstance(); + expect(() => new PerformanceMonitor()).toThrow('Use PerformanceMonitor.getInstance()'); + }); + + // ── Start / Stop ─────────────────────────────────────────── + + it('start() is idempotent', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.start(); + const timer1 = monitor._timer; + monitor.start(); // second call + expect(monitor._timer).toBe(timer1); // same timer + monitor.stop(); + }); + + it('stop() clears the timer', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.start(); + expect(monitor._timer).not.toBeNull(); + monitor.stop(); + expect(monitor._timer).toBeNull(); + }); + + // ── Thresholds ───────────────────────────────────────────── + + it('returns default thresholds', () => { + const monitor = PerformanceMonitor.getInstance(); + expect(monitor.getThresholds()).toEqual(DEFAULT_THRESHOLDS); + }); + + it('updates thresholds partially', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds({ memoryHeapMb: 256 }); + const thresholds = monitor.getThresholds(); + expect(thresholds.memoryHeapMb).toBe(256); + // Other fields unchanged + expect(thresholds.cpuPercent).toBe(DEFAULT_THRESHOLDS.cpuPercent); + }); + + it('getThresholds returns a copy', () => { + const monitor = PerformanceMonitor.getInstance(); + const t1 = monitor.getThresholds(); + t1.memoryHeapMb = 9999; + expect(monitor.getThresholds().memoryHeapMb).toBe(DEFAULT_THRESHOLDS.memoryHeapMb); + }); + + // ── Response Times ───────────────────────────────────────── + + it('records response times', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.recordResponseTime('ping', 42, 'command'); + monitor.recordResponseTime('status', 15, 'command'); + const snap = monitor.getSnapshot(); + expect(snap.responseTimes).toHaveLength(2); + expect(snap.responseTimes[0].name).toBe('ping'); + expect(snap.responseTimes[0].durationMs).toBe(42); + expect(snap.responseTimes[0].type).toBe('command'); + }); + + it('defaults type to command', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.recordResponseTime('test', 10); + const snap = monitor.getSnapshot(); + expect(snap.responseTimes[0].type).toBe('command'); + }); + + it('records api type', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.recordResponseTime('GET /health', 25, 'api'); + const snap = monitor.getSnapshot(); + expect(snap.responseTimes[0].type).toBe('api'); + }); + + // ── Summary Statistics ───────────────────────────────────── + + it('returns zero summary when no response times recorded', () => { + const monitor = PerformanceMonitor.getInstance(); + const snap = monitor.getSnapshot(); + expect(snap.summary).toEqual({ count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 }); + }); + + it('computes correct summary stats', () => { + const monitor = PerformanceMonitor.getInstance(); + // Deterministic dataset: 10 values 10..100 + for (let i = 1; i <= 10; i++) { + monitor.recordResponseTime('cmd', i * 10); + } + const { summary } = monitor.getSnapshot(); + expect(summary.count).toBe(10); + expect(summary.maxMs).toBe(100); + expect(summary.avgMs).toBe(55); // (10+20+...+100)/10 = 55 + }); + + // ── Alerts ──────────────────────────────────────────────── + + it('fires alert callback when threshold exceeded', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds({ responseTimeMs: 100 }); + const cb = vi.fn(); + monitor.onAlert(cb); + monitor.recordResponseTime('slow-cmd', 200); + expect(cb).toHaveBeenCalledWith('responseTimeMs', 200, 100, 'slow-cmd'); + }); + + it('does not fire alert when under threshold', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds({ responseTimeMs: 1000 }); + const cb = vi.fn(); + monitor.onAlert(cb); + monitor.recordResponseTime('fast-cmd', 50); + expect(cb).not.toHaveBeenCalled(); + }); + + it('respects alert cooldown (5 minutes)', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds({ responseTimeMs: 10 }); + const cb = vi.fn(); + monitor.onAlert(cb); + + monitor.recordResponseTime('cmd', 100); // triggers alert + monitor.recordResponseTime('cmd', 100); // within cooldown — no alert + expect(cb).toHaveBeenCalledTimes(1); + + // Advance past cooldown + vi.advanceTimersByTime(6 * 60 * 1000); + monitor.recordResponseTime('cmd', 100); // should trigger again + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('handles alert callback errors gracefully', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.setThresholds({ responseTimeMs: 10 }); + monitor.onAlert(() => { + throw new Error('callback exploded'); + }); + // Should not throw + expect(() => monitor.recordResponseTime('cmd', 100)).not.toThrow(); + }); + + // ── Snapshot Structure ───────────────────────────────────── + + it('getSnapshot returns expected shape', () => { + const monitor = PerformanceMonitor.getInstance(); + const snap = monitor.getSnapshot(); + expect(snap).toHaveProperty('current'); + expect(snap).toHaveProperty('thresholds'); + expect(snap).toHaveProperty('timeSeries'); + expect(snap).toHaveProperty('responseTimes'); + expect(snap).toHaveProperty('summary'); + expect(snap.current).toHaveProperty('memoryHeapMb'); + expect(snap.current).toHaveProperty('memoryRssMb'); + expect(snap.current).toHaveProperty('cpuPercent'); + expect(snap.current).toHaveProperty('uptime'); + expect(snap.timeSeries).toHaveProperty('memoryHeapMb'); + expect(snap.timeSeries).toHaveProperty('memoryRssMb'); + expect(snap.timeSeries).toHaveProperty('cpuPercent'); + }); + + // ── Periodic Sampling ────────────────────────────────────── + + it('populates time-series on interval tick', () => { + const monitor = PerformanceMonitor.getInstance(); + monitor.start(); + + // Initially no samples + expect(monitor.getSnapshot().timeSeries.memoryHeapMb).toHaveLength(0); + + // Advance one interval + vi.advanceTimersByTime(30_000); + const snap = monitor.getSnapshot(); + expect(snap.timeSeries.memoryHeapMb).toHaveLength(1); + expect(snap.timeSeries.memoryRssMb).toHaveLength(1); + expect(snap.timeSeries.cpuPercent).toHaveLength(1); + + // Advance another interval + vi.advanceTimersByTime(30_000); + expect(monitor.getSnapshot().timeSeries.memoryHeapMb).toHaveLength(2); + }); +}); From e945823c1001e6ed23fae06b77db18e028118bc2 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 23:42:30 -0500 Subject: [PATCH 3/4] feat: add performance dashboard with charts and threshold editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /dashboard/performance page with recharts area/bar charts - Memory usage (heap + RSS) over time — AreaChart - CPU utilization over time — AreaChart - Response time histogram (500ms buckets) — BarChart - Recent response times table with type badges - Alert threshold editor (save via PUT /api/v1/performance/thresholds) - KPI cards for heap, RSS, CPU, uptime, and percentile response times - Auto-refreshes every 30s; manual refresh button - Sidebar: Activity icon + Performance nav link - Next.js proxy routes: GET/PUT /api/performance and /api/performance/thresholds --- web/src/app/api/performance/route.ts | 31 + .../app/api/performance/thresholds/route.ts | 69 +++ web/src/app/dashboard/performance/page.tsx | 5 + .../dashboard/performance-dashboard.tsx | 550 ++++++++++++++++++ web/src/components/layout/sidebar.tsx | 6 + 5 files changed, 661 insertions(+) create mode 100644 web/src/app/api/performance/route.ts create mode 100644 web/src/app/api/performance/thresholds/route.ts create mode 100644 web/src/app/dashboard/performance/page.tsx create mode 100644 web/src/components/dashboard/performance-dashboard.tsx diff --git a/web/src/app/api/performance/route.ts b/web/src/app/api/performance/route.ts new file mode 100644 index 000000000..79158c4d0 --- /dev/null +++ b/web/src/app/api/performance/route.ts @@ -0,0 +1,31 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { buildUpstreamUrl, getBotApiConfig, proxyToBotApi } from '@/lib/bot-api-proxy'; + +export const dynamic = 'force-dynamic'; + +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 config = getBotApiConfig('[api/performance]'); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl(config.baseUrl, '/performance', '[api/performance]'); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi( + upstreamUrl, + config.secret, + '[api/performance]', + 'Failed to fetch performance data', + ); +} diff --git a/web/src/app/api/performance/thresholds/route.ts b/web/src/app/api/performance/thresholds/route.ts new file mode 100644 index 000000000..5fa7bcb3f --- /dev/null +++ b/web/src/app/api/performance/thresholds/route.ts @@ -0,0 +1,69 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { buildUpstreamUrl, getBotApiConfig, proxyToBotApi } from '@/lib/bot-api-proxy'; + +export const dynamic = 'force-dynamic'; + +async function authorize(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 }); + } + return null; +} + +export async function GET(request: NextRequest) { + const authError = await authorize(request); + if (authError) return authError; + + const config = getBotApiConfig('[api/performance/thresholds]'); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + '/performance/thresholds', + '[api/performance/thresholds]', + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi( + upstreamUrl, + config.secret, + '[api/performance/thresholds]', + 'Failed to fetch thresholds', + ); +} + +export async function PUT(request: NextRequest) { + const authError = await authorize(request); + if (authError) return authError; + + const config = getBotApiConfig('[api/performance/thresholds]'); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + '/performance/thresholds', + '[api/performance/thresholds]', + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + return proxyToBotApi( + upstreamUrl, + config.secret, + '[api/performance/thresholds]', + 'Failed to update thresholds', + { method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }, + ); +} diff --git a/web/src/app/dashboard/performance/page.tsx b/web/src/app/dashboard/performance/page.tsx new file mode 100644 index 000000000..a41c3a9de --- /dev/null +++ b/web/src/app/dashboard/performance/page.tsx @@ -0,0 +1,5 @@ +import { PerformanceDashboard } from '@/components/dashboard/performance-dashboard'; + +export default function PerformancePage() { + return ; +} diff --git a/web/src/components/dashboard/performance-dashboard.tsx b/web/src/components/dashboard/performance-dashboard.tsx new file mode 100644 index 000000000..5fcfa899e --- /dev/null +++ b/web/src/components/dashboard/performance-dashboard.tsx @@ -0,0 +1,550 @@ +'use client'; + +import { Activity, AlertTriangle, Clock, Cpu, HardDrive, RefreshCw, Zap } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +interface MetricPoint { + timestamp: number; + value: number; +} + +interface ResponseTimeSample { + timestamp: number; + name: string; + durationMs: number; + type: 'command' | 'api'; +} + +interface PerformanceSummary { + count: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + p99Ms: number; + maxMs: number; +} + +interface AlertThresholds { + memoryHeapMb: number; + memoryRssMb: number; + cpuPercent: number; + responseTimeMs: number; +} + +interface PerformanceSnapshot { + current: { + memoryHeapMb: number; + memoryRssMb: number; + memoryHeapTotalMb: number; + memoryExternalMb: number; + cpuPercent: number; + uptime: number; + }; + thresholds: AlertThresholds; + timeSeries: { + memoryHeapMb: MetricPoint[]; + memoryRssMb: MetricPoint[]; + cpuPercent: MetricPoint[]; + }; + responseTimes: ResponseTimeSample[]; + summary: PerformanceSummary; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m ${Math.floor(seconds % 60)}s`; +} + +function formatTs(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +// ─── Stat Card ────────────────────────────────────────────────────────────── + +interface StatCardProps { + title: string; + value: string; + subtitle?: string; + icon: React.ElementType; + alert?: boolean; + loading?: boolean; +} + +function StatCard({ title, value, subtitle, icon: Icon, alert, loading }: StatCardProps) { + return ( + + + {title} + + + + {loading ? ( +
+ ) : ( + <> +
{value}
+ {subtitle &&

{subtitle}

} + + )} + + + ); +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +const AUTO_REFRESH_MS = 30_000; + +export function PerformanceDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [thresholdEdit, setThresholdEdit] = useState>({}); + const [thresholdSaving, setThresholdSaving] = useState(false); + const [thresholdMsg, setThresholdMsg] = useState(null); + const abortRef = useRef(null); + + const fetchData = useCallback(async (bg = false) => { + abortRef.current?.abort(); + const ctl = new AbortController(); + abortRef.current = ctl; + if (!bg) { + setLoading(true); + setError(null); + } + try { + const res = await fetch('/api/performance', { cache: 'no-store', signal: ctl.signal }); + if (!res.ok) { + const json: unknown = await res.json().catch(() => ({})); + const msg = + typeof json === 'object' && json !== null && 'error' in json + ? String((json as Record).error) + : 'Failed to fetch performance data'; + throw new Error(msg); + } + const json: PerformanceSnapshot = (await res.json()) as PerformanceSnapshot; + setData(json); + setLastUpdated(new Date()); + setError(null); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (!bg) setLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + void fetchData(); + return () => abortRef.current?.abort(); + }, [fetchData]); + + // Auto-refresh every 30s + useEffect(() => { + const id = window.setInterval(() => void fetchData(true), AUTO_REFRESH_MS); + return () => window.clearInterval(id); + }, [fetchData]); + + // Seed threshold editor on data load + useEffect(() => { + if (data && Object.keys(thresholdEdit).length === 0) { + setThresholdEdit({ ...data.thresholds }); + } + }, [data, thresholdEdit]); + + const saveThresholds = async () => { + setThresholdSaving(true); + setThresholdMsg(null); + try { + const res = await fetch('/api/performance/thresholds', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(thresholdEdit), + }); + if (!res.ok) { + const json: unknown = await res.json().catch(() => ({})); + const msg = + typeof json === 'object' && json !== null && 'error' in json + ? String((json as Record).error) + : 'Failed to save thresholds'; + setThresholdMsg(`Error: ${msg}`); + return; + } + setThresholdMsg('Thresholds saved.'); + void fetchData(true); + } catch { + setThresholdMsg('Error: Network failure'); + } finally { + setThresholdSaving(false); + } + }; + + // ── Derived chart data ───────────────────────────────────── + + const memChartData = + data?.timeSeries.memoryHeapMb.map((pt, i) => ({ + time: formatTs(pt.timestamp), + heap: pt.value, + rss: data.timeSeries.memoryRssMb[i]?.value ?? 0, + })) ?? []; + + const cpuChartData = + data?.timeSeries.cpuPercent.map((pt) => ({ + time: formatTs(pt.timestamp), + cpu: pt.value, + })) ?? []; + + // Group response times into a histogram (bucket by 500ms) + const rtBuckets: Record = {}; + for (const sample of data?.responseTimes ?? []) { + const bucket = `${Math.floor(sample.durationMs / 500) * 500}ms`; + rtBuckets[bucket] = (rtBuckets[bucket] ?? 0) + 1; + } + const rtHistogram = Object.entries(rtBuckets) + .sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10)) + .map(([bucket, count]) => ({ bucket, count })); + + const cur = data?.current; + const thresh = data?.thresholds; + const sum = data?.summary; + + return ( +
+ {/* Header */} +
+
+

Performance

+

+ Memory, CPU, and response time metrics. Auto-refreshes every 30s. +

+ {lastUpdated && ( +

+ Last updated{' '} + {lastUpdated.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} +

+ )} +
+ +
+ + {/* Error banner */} + {error && ( +
+ Failed to load performance data: {error} + +
+ )} + + {/* KPI cards */} +
+ thresh.memoryHeapMb * 0.9} + loading={loading && !data} + /> + thresh.memoryRssMb * 0.9} + loading={loading && !data} + /> + thresh.cpuPercent * 0.9} + loading={loading && !data} + /> + +
+ + {/* Response time summary */} +
+ + + thresh.responseTimeMs} + loading={loading && !data} + /> + thresh.responseTimeMs} + loading={loading && !data} + /> +
+ + {/* Memory time-series chart */} + + + Memory Usage Over Time + Heap and RSS memory sampled every 30s (last 60 minutes) + + + {memChartData.length === 0 ? ( +

+ No samples yet — data appears after the first 30-second interval. +

+ ) : ( + + + + + + [`${v} MB`]} /> + + + + + )} +
+
+ + {/* CPU time-series chart */} + + + CPU Utilization Over Time + Process CPU usage sampled every 30s + + + {cpuChartData.length === 0 ? ( +

+ No samples yet — data appears after the first 30-second interval. +

+ ) : ( + + + + + + [`${v}%`]} /> + + + + )} +
+
+ + {/* Response time histogram */} + + + Response Time Distribution + + Histogram of command and API response times (500ms buckets) · {sum?.count ?? 0} total + samples + + + + {rtHistogram.length === 0 ? ( +

+ No response times recorded yet. +

+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Recent response times table */} + {(data?.responseTimes?.length ?? 0) > 0 && ( + + + Recent Response Times + + Last {Math.min(data?.responseTimes?.length ?? 0, 20)} samples + + + +
+ + + + + + + + + + + {[...(data?.responseTimes ?? [])] + .reverse() + .slice(0, 20) + .map((s) => ( + + + + + + + ))} + +
TimeNameTypeDuration
+ {formatTs(s.timestamp)} + {s.name} + + {s.type} + + thresh.responseTimeMs ? 'text-destructive' : '' + }`} + > + {s.durationMs} ms +
+
+
+
+ )} + + {/* Alert thresholds editor */} + + + Alert Thresholds + + Configure when the bot logs a warning and triggers alert callbacks. Changes take effect + immediately. + + + +
+ {( + [ + { key: 'memoryHeapMb', label: 'Heap Memory (MB)' }, + { key: 'memoryRssMb', label: 'RSS Memory (MB)' }, + { key: 'cpuPercent', label: 'CPU Utilization (%)' }, + { key: 'responseTimeMs', label: 'Response Time (ms)' }, + ] as const + ).map(({ key, label }) => ( +
+ + + setThresholdEdit((prev) => ({ + ...prev, + [key]: Number(e.target.value), + })) + } + /> +
+ ))} +
+ +
+ + {thresholdMsg && ( +

+ {thresholdMsg} +

+ )} +
+
+
+
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 7e4a3084e..c6015577d 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { + Activity, Bot, ClipboardList, LayoutDashboard, @@ -58,6 +59,11 @@ const navigation = [ href: '/dashboard/audit-log', icon: ClipboardList, }, + { + name: 'Performance', + href: '/dashboard/performance', + icon: Activity, + }, { name: 'Logs', href: '/dashboard/logs', From 429ee465f1399a6a065bddf5b25c00ad18063d0b Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Mon, 2 Mar 2026 06:43:58 -0500 Subject: [PATCH 4/4] style: fix lint issues after merge --- src/api/index.js | 3 +-- src/api/server.js | 2 +- src/index.js | 2 +- tests/api/routes/performance.test.js | 4 +++- tests/api/utils/ssrfProtection.test.js | 2 +- tests/modules/events-uncaught-exit.test.js | 8 ++++---- web/src/components/dashboard/config-editor.tsx | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/api/index.js b/src/api/index.js index 478e5bb9b..1f1c3037e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -17,9 +17,9 @@ import healthRouter from './routes/health.js'; import membersRouter from './routes/members.js'; import moderationRouter from './routes/moderation.js'; import notificationsRouter from './routes/notifications.js'; +import performanceRouter from './routes/performance.js'; import ticketsRouter from './routes/tickets.js'; import webhooksRouter from './routes/webhooks.js'; -import performanceRouter from './routes/performance.js'; const router = Router(); @@ -70,4 +70,3 @@ router.use('/guilds', requireAuth(), auditLogMiddleware(), notificationsRouter); router.use('/webhooks', requireAuth(), webhooksRouter); export default router; - diff --git a/src/api/server.js b/src/api/server.js index 0711036f1..5c502c391 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -5,8 +5,8 @@ import express from 'express'; import { error, info, warn } from '../logger.js'; -import apiRouter from './index.js'; import { PerformanceMonitor } from '../modules/performanceMonitor.js'; +import apiRouter from './index.js'; import { rateLimit } from './middleware/rateLimit.js'; import { stopAuthCleanup } from './routes/auth.js'; import { swaggerSpec } from './swagger.js'; diff --git a/src/index.js b/src/index.js index ef4152fbd..6b32375e1 100644 --- a/src/index.js +++ b/src/index.js @@ -49,6 +49,7 @@ import { startGithubFeed, stopGithubFeed } from './modules/githubFeed.js'; import { checkMem0Health, markUnavailable } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { loadOptOuts } from './modules/optout.js'; +import { PerformanceMonitor } from './modules/performanceMonitor.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; import { startTriage, stopTriage } from './modules/triage.js'; import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js'; @@ -62,7 +63,6 @@ import { MEMORY_DEGRADED_THRESHOLD, measureEventLoopLag, } from './utils/health.js'; -import { PerformanceMonitor } from './modules/performanceMonitor.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; import { registerCommands } from './utils/registerCommands.js'; diff --git a/tests/api/routes/performance.test.js b/tests/api/routes/performance.test.js index f7e676bcb..42a8fe362 100644 --- a/tests/api/routes/performance.test.js +++ b/tests/api/routes/performance.test.js @@ -148,7 +148,9 @@ describe('PUT /api/v1/performance/thresholds', () => { it('returns 401 without auth', async () => { const app = buildApp(); - const res = await request(app).put('/api/v1/performance/thresholds').send({ memoryHeapMb: 256 }); + const res = await request(app) + .put('/api/v1/performance/thresholds') + .send({ memoryHeapMb: 256 }); expect(res.status).toBe(401); }); diff --git a/tests/api/utils/ssrfProtection.test.js b/tests/api/utils/ssrfProtection.test.js index 2f024ec52..9355a2556 100644 --- a/tests/api/utils/ssrfProtection.test.js +++ b/tests/api/utils/ssrfProtection.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { isBlockedIp, validateUrlForSsrf, diff --git a/tests/modules/events-uncaught-exit.test.js b/tests/modules/events-uncaught-exit.test.js index 6fd959c61..dc1aba200 100644 --- a/tests/modules/events-uncaught-exit.test.js +++ b/tests/modules/events-uncaught-exit.test.js @@ -105,7 +105,7 @@ describe('registerErrorHandlers — uncaughtException exits process (issue #156) const { registerErrorHandlers } = await import('../../src/modules/events.js'); registerErrorHandlers({ on: vi.fn() }); - expect(capturedHandlers['uncaughtException']).toBeDefined(); + expect(capturedHandlers.uncaughtException).toBeDefined(); }); it('calls process.exit(1) after logging an uncaught exception', async () => { @@ -122,9 +122,9 @@ describe('registerErrorHandlers — uncaughtException exits process (issue #156) const { registerErrorHandlers } = await import('../../src/modules/events.js'); registerErrorHandlers({ on: vi.fn() }); - expect(capturedHandlers['uncaughtException']).toBeDefined(); + expect(capturedHandlers.uncaughtException).toBeDefined(); - await capturedHandlers['uncaughtException'](new Error('boom')); + await capturedHandlers.uncaughtException(new Error('boom')); expect(processExitSpy).toHaveBeenCalledWith(1); }); @@ -145,7 +145,7 @@ describe('registerErrorHandlers — uncaughtException exits process (issue #156) const { registerErrorHandlers } = await import('../../src/modules/events.js'); registerErrorHandlers({ on: vi.fn() }); - await capturedHandlers['uncaughtException'](new Error('boom while sentry is down')); + await capturedHandlers.uncaughtException(new Error('boom while sentry is down')); // Must still exit even when Sentry fails — handler has a catch block expect(processExitSpy).toHaveBeenCalledWith(1); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 7e3255c50..335c170c4 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -423,7 +423,7 @@ export function ConfigEditor() { clearTimeout(autoSaveTimerRef.current); } }; - }, [draftConfig, hasChanges, hasValidationErrors, saving, saveChanges]); + }, [hasChanges, hasValidationErrors, saving, saveChanges]); // ── Keyboard shortcut: Ctrl/Cmd+S to save ────────────────────── useEffect(() => {