fix(monitoring): address code review findings

- SSE stream: detect client disconnect via request.signal to stop
  polling loop (prevents wasted DB queries after tab close)
- AlertEvaluator: split shouldFire/recordFired so cooldown is only
  recorded after successful dispatch (prevents alert suppression
  on dispatch failure)
- SnapshotCollector: cache payload instance (avoid re-importing on
  every 60s tick)
- Alert acknowledge: validate alertId type (string|number)
- Logs search: add 300ms debounce to prevent query-per-keystroke
- Replace remaining `any` cast with Record<string, unknown>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-15 01:02:50 +00:00
parent 96b8cca9d6
commit 086dd269fa
6 changed files with 65 additions and 30 deletions

View file

@ -7,16 +7,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: req.headers })
if (!user || !(user as any).isSuperAdmin) {
if (!user || !(user as Record<string, unknown>).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 },
)
}

View file

@ -45,12 +45,18 @@ export async function GET(request: NextRequest): Promise<Response> {
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<Response> {
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()
},
})

View file

@ -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<Set<string>>(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 {
</select>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Suche..."
className="monitoring__input"
/>

View file

@ -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)
}
}
}

View file

@ -12,6 +12,17 @@ import { AlertEvaluator } from './alert-evaluator.js'
let interval: ReturnType<typeof setInterval> | null = null
const alertEvaluator = new AlertEvaluator()
/** Cached Payload instance — resolved once, reused on every tick. */
let cachedPayload: { create: (...args: unknown[]) => Promise<unknown>; find: (...args: unknown[]) => Promise<unknown> } | 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<void> {
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<void> {
async function collectAndSave(): Promise<void> {
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<void> {
})
// 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
}
}

View file

@ -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)
})
})