diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts index 13c20ede..27c329ef 100644 --- a/backend/src/__tests__/dashboard-routes.test.ts +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -6,11 +6,15 @@ * - GET /api/dashboard/configuration returns the documented shape and * applies tier-correct `locked` flags for the Community and paid * personas (toggled via LicenseService spies). + * - Alert-rule counts are scoped to stacks present on the active node + * (dashboard and fleet local-node row agree on exact cardinality). * - GET /api/dashboard/stack-restarts clamps the `days` query parameter * to [1, 30] and falls back to 7 for invalid inputs. * - Neither endpoint leaks secret material (agent URLs, tokens) in the * response payload. */ +import fs from 'fs'; +import path from 'path'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; @@ -192,6 +196,57 @@ describe('GET /api/dashboard/configuration', () => { db.getDb().prepare('DELETE FROM agents WHERE url = ?').run(SECRET_URL); } }); + + it('scopes alertRules to stacks on the active node for dashboard and fleet local row', async () => { + const db = DatabaseService.getInstance(); + const composeDir = process.env.COMPOSE_DIR as string; + const stackName = 'cfg-alert-scope'; + const stackDir = path.join(composeDir, stackName); + const alertIds: number[] = []; + + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync( + path.join(stackDir, 'compose.yaml'), + 'services:\n web:\n image: nginx:latest\n', + ); + + const baseAlert = { + service_name: null as string | null, + metric: 'cpu_percent', + operator: '>', + threshold: 80, + duration_mins: 5, + cooldown_mins: 15, + last_fired_at: 0, + }; + + try { + // Two rules on a discovered stack (exact rule count, not unique stacks) + // plus one orphaned rule that must not inflate the scoped count. + alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 80 }).id!); + alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 90 }).id!); + alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: 'cfg-alert-orphan', threshold: 70 }).id!); + + const dash = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(dash.status).toBe(200); + expect(dash.body.notifications.alertRules).toBe(2); + + const fleet = await request(app).get('/api/fleet/configuration').set('Cookie', adminCookie); + expect(fleet.status).toBe(200); + expect(Array.isArray(fleet.body)).toBe(true); + const localRow = fleet.body.find( + (row: { type: string; configuration: { notifications: { alertRules: number } } | null }) => + row.type === 'local' && row.configuration != null, + ); + expect(localRow).toBeDefined(); + expect(localRow.configuration.notifications.alertRules).toBe(2); + } finally { + for (const id of alertIds) { + db.deleteStackAlert(id); + } + fs.rmSync(stackDir, { recursive: true, force: true }); + } + }); }); describe('GET /api/dashboard/stack-restarts', () => { diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index b99797df..a4aedddc 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService'; import { CloudBackupService } from '../services/CloudBackupService'; +import { FileSystemService } from '../services/FileSystemService'; import TrivyService from '../services/TrivyService'; import { effectiveTier } from '../middleware/tierGates'; import { isDebugEnabled } from '../utils/debug'; @@ -49,11 +50,11 @@ export interface ConfigurationStatus { }; } -export function buildLocalConfigurationStatus( +export async function buildLocalConfigurationStatus( nodeId: number, userId: number, tier: LicenseTier, -): ConfigurationStatus { +): Promise { const db = DatabaseService.getInstance(); const agents = db.getAgents(nodeId); @@ -62,7 +63,10 @@ export function buildLocalConfigurationStatus( return { configured: !!a?.url, enabled: a?.enabled ?? false }; }; - const alertRules = db.getStackAlerts().length; + // Scope to stacks that exist on this node. stack_alerts has no node_id; + // intersecting with the node's compose directory is the per-node filter. + const stackNames = new Set(await FileSystemService.getInstance(nodeId).getStacks()); + const alertRules = db.getStackAlerts().filter((a) => stackNames.has(a.stack_name)).length; const notifRoutes = db.getNotificationRoutes(); const healPolicies = db.getAutoHealPolicies(undefined, nodeId); @@ -165,7 +169,7 @@ export function buildLocalConfigurationStatus( } // All routes below are protected by the global authGate mounted at app.use('/api', authGate) -dashboardRouter.get('/configuration', (req: Request, res: Response): void => { +dashboardRouter.get('/configuration', async (req: Request, res: Response): Promise => { try { const debug = isDebugEnabled(); const startedAt = debug ? Date.now() : 0; @@ -173,7 +177,7 @@ dashboardRouter.get('/configuration', (req: Request, res: Response): void => { const userId = req.user?.userId ?? 0; const tier = effectiveTier(req); - const payload = buildLocalConfigurationStatus(nodeId, userId, tier); + const payload = await buildLocalConfigurationStatus(nodeId, userId, tier); if (debug) { console.debug( `[Dashboard:debug] /configuration built in ${Date.now() - startedAt} ms (nodeId=${nodeId})`, diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index fd81e12c..a97129e4 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -659,7 +659,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp name: node.name, type: 'local', status: 'online', - configuration: buildLocalConfigurationStatus(node.id, userId, localTier), + configuration: await buildLocalConfigurationStatus(node.id, userId, localTier), }; }