diff --git a/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts index d457a23..4eaecba 100644 --- a/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts +++ b/src/app/(payload)/api/monitoring/alerts/acknowledge/route.ts @@ -7,16 +7,16 @@ export async function POST(req: NextRequest): Promise { const payload = await getPayload({ config }) const { user } = await payload.auth({ headers: req.headers }) - if (!user || !(user as any).isSuperAdmin) { + if (!user || !(user as Record).isSuperAdmin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const body = await req.json() const { alertId } = body - if (!alertId) { + if (!alertId || (typeof alertId !== 'string' && typeof alertId !== 'number')) { return NextResponse.json( - { error: 'alertId is required' }, + { error: 'alertId must be a string or number' }, { status: 400 }, ) } diff --git a/src/app/(payload)/api/monitoring/stream/route.ts b/src/app/(payload)/api/monitoring/stream/route.ts index 61537f2..f75fb0b 100644 --- a/src/app/(payload)/api/monitoring/stream/route.ts +++ b/src/app/(payload)/api/monitoring/stream/route.ts @@ -45,12 +45,18 @@ export async function GET(request: NextRequest): Promise { const stream = new ReadableStream({ async start(controller) { const startTime = Date.now() + let cancelled = false + + // Stop polling when the client disconnects + request.signal.addEventListener('abort', () => { + cancelled = true + }) controller.enqueue( encoder.encode(formatSSE('connected', { timestamp: new Date().toISOString() })), ) - while (Date.now() - startTime < MAX_DURATION_MS) { + while (!cancelled && Date.now() - startTime < MAX_DURATION_MS) { try { const now = Date.now() @@ -70,22 +76,21 @@ export async function GET(request: NextRequest): Promise { lastLogCheck = await emitNewLogs(payload, controller, encoder, lastLogCheck) await new Promise((resolve) => setTimeout(resolve, CYCLE_INTERVAL_MS)) - } catch (error) { - console.error('[MonitoringSSE] Error:', error) - controller.enqueue( - encoder.encode( - formatSSE('error', { - message: 'Internal error', - timestamp: new Date().toISOString(), - }), - ), - ) + } catch { + // Stream closed or other error — stop the loop + break } } - controller.enqueue( - encoder.encode(formatSSE('reconnect', { timestamp: new Date().toISOString() })), - ) + if (!cancelled) { + try { + controller.enqueue( + encoder.encode(formatSSE('reconnect', { timestamp: new Date().toISOString() })), + ) + } catch { + // Client already disconnected + } + } controller.close() }, }) diff --git a/src/components/admin/MonitoringDashboard.tsx b/src/components/admin/MonitoringDashboard.tsx index c66482c..d74e7a0 100644 --- a/src/components/admin/MonitoringDashboard.tsx +++ b/src/components/admin/MonitoringDashboard.tsx @@ -534,9 +534,16 @@ function LogsTab({ newLogs }: LogsTabProps): React.ReactElement { const [totalPages, setTotalPages] = useState(1) const [level, setLevel] = useState('') const [source, setSource] = useState('') + const [searchInput, setSearchInput] = useState('') const [search, setSearch] = useState('') const [expanded, setExpanded] = useState>(new Set()) + // Debounce search input to avoid querying on every keystroke + useEffect(() => { + const timer = setTimeout(() => setSearch(searchInput), 300) + return () => clearTimeout(timer) + }, [searchInput]) + useEffect(() => { const params = new URLSearchParams({ page: String(page), limit: '50' }) if (level) params.set('level', level) @@ -589,8 +596,8 @@ function LogsTab({ newLogs }: LogsTabProps): React.ReactElement { setSearch(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} placeholder="Suche..." className="monitoring__input" /> diff --git a/src/lib/monitoring/alert-evaluator.ts b/src/lib/monitoring/alert-evaluator.ts index e7000a4..a1b1c34 100644 --- a/src/lib/monitoring/alert-evaluator.ts +++ b/src/lib/monitoring/alert-evaluator.ts @@ -93,7 +93,6 @@ export class AlertEvaluator { /** * Returns true if the rule should fire (not in cooldown). - * Records the current time as last-fired when returning true. */ shouldFire(ruleId: string, cooldownMinutes: number): boolean { const lastFired = this.cooldownMap.get(ruleId) @@ -101,10 +100,14 @@ export class AlertEvaluator { const elapsedMinutes = (Date.now() - lastFired) / 60_000 if (elapsedMinutes < cooldownMinutes) return false } - this.cooldownMap.set(ruleId, Date.now()) return true } + /** Record that a rule fired successfully. */ + recordFired(ruleId: string): void { + this.cooldownMap.set(ruleId, Date.now()) + } + /** * Evaluates all enabled rules against current metrics. * Fires alerts for rules that match and are not in cooldown. @@ -131,6 +134,7 @@ export class AlertEvaluator { if (evaluateCondition(rule.condition, value, rule.threshold)) { if (this.shouldFire(rule.id, rule.cooldownMinutes)) { await this.dispatchAlert(payload, rule, value) + this.recordFired(rule.id) } } } diff --git a/src/lib/monitoring/snapshot-collector.ts b/src/lib/monitoring/snapshot-collector.ts index 5743563..85e210d 100644 --- a/src/lib/monitoring/snapshot-collector.ts +++ b/src/lib/monitoring/snapshot-collector.ts @@ -12,6 +12,17 @@ import { AlertEvaluator } from './alert-evaluator.js' let interval: ReturnType | null = null const alertEvaluator = new AlertEvaluator() +/** Cached Payload instance — resolved once, reused on every tick. */ +let cachedPayload: { create: (...args: unknown[]) => Promise; find: (...args: unknown[]) => Promise } | null = null + +async function getPayloadInstance() { + if (cachedPayload) return cachedPayload + const { getPayload } = await import('payload') + const config = (await import(/* @vite-ignore */ '@payload-config')).default + cachedPayload = await getPayload({ config }) + return cachedPayload +} + export async function startSnapshotCollector(): Promise { const INTERVAL = parseInt(process.env.MONITORING_SNAPSHOT_INTERVAL || '60000', 10) console.log(`[SnapshotCollector] Starting (interval: ${INTERVAL}ms)`) @@ -26,13 +37,11 @@ export async function startSnapshotCollector(): Promise { async function collectAndSave(): Promise { try { - const { getPayload } = await import('payload') - const config = (await import('@payload-config')).default - const payload = await getPayload({ config }) + const payload = await getPayloadInstance() const metrics = await collectMetrics() - await payload.create({ + await (payload as any).create({ collection: 'monitoring-snapshots', data: { timestamp: new Date().toISOString(), @@ -41,9 +50,11 @@ async function collectAndSave(): Promise { }) // Evaluate alert rules against collected metrics - await alertEvaluator.evaluateRules(payload, metrics) + await alertEvaluator.evaluateRules(payload as any, metrics) } catch (error) { console.error('[SnapshotCollector] Error:', error) + // Reset cache on error so next tick re-resolves + cachedPayload = null } } diff --git a/tests/unit/monitoring/alert-evaluator.unit.spec.ts b/tests/unit/monitoring/alert-evaluator.unit.spec.ts index becdf62..b8f6915 100644 --- a/tests/unit/monitoring/alert-evaluator.unit.spec.ts +++ b/tests/unit/monitoring/alert-evaluator.unit.spec.ts @@ -72,29 +72,37 @@ describe('evaluateCondition', () => { }) }) -describe('AlertEvaluator.shouldFire', () => { +describe('AlertEvaluator.shouldFire + recordFired', () => { it('allows first fire', () => { const evaluator = new AlertEvaluator() expect(evaluator.shouldFire('rule-1', 15)).toBe(true) }) - it('blocks during cooldown', () => { + it('blocks during cooldown after recordFired', () => { const evaluator = new AlertEvaluator() expect(evaluator.shouldFire('rule-1', 15)).toBe(true) + evaluator.recordFired('rule-1') expect(evaluator.shouldFire('rule-1', 15)).toBe(false) }) + it('allows re-check if recordFired was not called', () => { + const evaluator = new AlertEvaluator() + expect(evaluator.shouldFire('rule-1', 15)).toBe(true) + // Without recordFired, the cooldown is not active + expect(evaluator.shouldFire('rule-1', 15)).toBe(true) + }) + it('different rules have independent cooldowns', () => { const evaluator = new AlertEvaluator() expect(evaluator.shouldFire('rule-1', 15)).toBe(true) + evaluator.recordFired('rule-1') expect(evaluator.shouldFire('rule-2', 15)).toBe(true) }) it('allows fire after cooldown expires', () => { const evaluator = new AlertEvaluator() - expect(evaluator.shouldFire('rule-1', 0)).toBe(true) + evaluator.recordFired('rule-1') // With 0-minute cooldown, immediate re-fire should be allowed - // (elapsed time > 0 which is >= 0) expect(evaluator.shouldFire('rule-1', 0)).toBe(true) }) })