diff --git a/app/api/debug/emit-test-notify/route.ts b/app/api/debug/emit-test-notify/route.ts new file mode 100644 index 0000000..e480258 --- /dev/null +++ b/app/api/debug/emit-test-notify/route.ts @@ -0,0 +1,59 @@ +// TIJDELIJKE debug-endpoint. Stuurt een handmatige pg_notify op +// `scrum4me_changes` zonder een echte UPDATE te doen. Bedoeld om de +// SSE-pipe te testen los van Prisma/triggers. +// +// VERWIJDEREN voor M8 out-of-draft. + +import { Client } from 'pg' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const CHANNEL = 'scrum4me_changes' + +export async function POST(request: Request) { + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) + } + + let body: unknown = null + try { + body = await request.json() + } catch { + // empty body is OK — we vullen defaults in + } + + const overrides = (body && typeof body === 'object' ? body : {}) as Record + const payload = { + op: 'U', + entity: 'task', + id: `debug-${Date.now()}`, + story_id: 'debug-story', + product_id: 'debug-product', + sprint_id: null, + assignee_id: null, + task_status: 'TO_DO', + task_sort_order: 1, + task_title: 'manual debug emit', + changed_fields: ['status'], + debug: true, + emitted_at: new Date().toISOString(), + ...overrides, + } + + const client = new Client({ connectionString: directUrl }) + try { + await client.connect() + // pg_notify met JSON-string als payload — zelfde formaat als de trigger + await client.query('SELECT pg_notify($1, $2)', [CHANNEL, JSON.stringify(payload)]) + return Response.json({ ok: true, payload }) + } catch (err) { + return Response.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } finally { + try { await client.end() } catch {} + } +} diff --git a/app/debug-realtime/client.tsx b/app/debug-realtime/client.tsx index 20618b8..0d8537a 100644 --- a/app/debug-realtime/client.tsx +++ b/app/debug-realtime/client.tsx @@ -1,96 +1,215 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' + +type RowType = 'ready' | 'message' | 'error' | 'open' | 'close' interface Row { - receivedAt: string - type: 'ready' | 'message' | 'error' + receivedAt: number + type: RowType raw: string + parsed?: Record } -const MAX_ROWS = 200 +interface Stats { + reconnects: number + totalEvents: number + firstEventAt: number | null + lastEventAt: number | null + largestGapMs: number + emitInFlight: boolean + lastEmitResult: string | null +} + +const MAX_ROWS = 500 +const HEARTBEAT_WARN_MS = 30_000 export function DebugRealtimeClient() { const [rows, setRows] = useState([]) const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting') + const [stats, setStats] = useState({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + const [filterType, setFilterType] = useState<'all' | RowType>('all') + const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all') + const [tickNow, setTickNow] = useState(() => Date.now()) + const sourceRef = useRef(null) + const lastEventTimeRef = useRef(null) + + // Tick every second om "since last event"-counter levend te houden + useEffect(() => { + const t = setInterval(() => setTickNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) useEffect(() => { - const source = new EventSource('/api/debug/realtime-stream') - sourceRef.current = source - const append = (row: Row) => { setRows((prev) => [row, ...prev].slice(0, MAX_ROWS)) + lastEventTimeRef.current = row.receivedAt + setStats((s) => { + const prevLast = s.lastEventAt + const gap = prevLast ? row.receivedAt - prevLast : 0 + return { + ...s, + totalEvents: s.totalEvents + 1, + firstEventAt: s.firstEventAt ?? row.receivedAt, + lastEventAt: row.receivedAt, + largestGapMs: Math.max(s.largestGapMs, gap), + } + }) } - source.addEventListener('ready', (e) => { - setStatus('open') - const data = (e as MessageEvent).data ?? '' - append({ receivedAt: new Date().toISOString(), type: 'ready', raw: data }) - }) + const open = () => { + const source = new EventSource('/api/debug/realtime-stream') + sourceRef.current = source - source.addEventListener('error', (e) => { - setStatus('error') - const data = (e as MessageEvent).data ?? '(no data)' - append({ receivedAt: new Date().toISOString(), type: 'error', raw: data }) - }) + append({ receivedAt: Date.now(), type: 'open', raw: '(EventSource opening)' }) + setStats((s) => ({ ...s, reconnects: s.reconnects + 1 })) + setStatus('connecting') - source.onmessage = (e) => { - append({ receivedAt: new Date().toISOString(), type: 'message', raw: e.data ?? '' }) + source.addEventListener('ready', (e) => { + setStatus('open') + const data = (e as MessageEvent).data ?? '' + const parsed = safeParse(data) + append({ receivedAt: Date.now(), type: 'ready', raw: data, parsed }) + }) + + source.addEventListener('error', (e) => { + setStatus('error') + const data = (e as MessageEvent).data ?? '(no data)' + append({ receivedAt: Date.now(), type: 'error', raw: data }) + }) + + source.onmessage = (e) => { + const parsed = safeParse(e.data ?? '') + append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed }) + } + + source.onerror = () => { + setStatus('error') + append({ receivedAt: Date.now(), type: 'close', raw: '(EventSource error/close)' }) + } } - source.onerror = () => { - setStatus('error') - } + open() return () => { - source.close() + sourceRef.current?.close() sourceRef.current = null } }, []) - function statusColor() { - switch (status) { - case 'open': - return 'green' - case 'error': - return 'red' - case 'closed': - return 'gray' - default: - return 'orange' + async function emitTestEvent() { + setStats((s) => ({ ...s, emitInFlight: true, lastEmitResult: null })) + try { + const res = await fetch('/api/debug/emit-test-notify', { method: 'POST' }) + const json = (await res.json()) as { ok?: boolean; error?: string } + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: json.ok ? 'sent ✓' : `failed: ${json.error ?? 'unknown'}`, + })) + } catch (err) { + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: `failed: ${err instanceof Error ? err.message : String(err)}`, + })) } } + function reset() { + setRows([]) + setStats({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + lastEventTimeRef.current = null + } + + const filteredRows = useMemo(() => { + return rows.filter((row) => { + if (filterType !== 'all' && row.type !== filterType) return false + if (filterEntity !== 'all') { + const entity = (row.parsed?.entity as string | undefined) ?? null + if (filterEntity === 'debug') { + if (!row.parsed?.debug) return false + } else { + if (entity !== filterEntity) return false + } + } + return true + }) + }, [rows, filterType, filterEntity]) + + const sinceLastEvent = lastEventTimeRef.current + ? tickNow - lastEventTimeRef.current + : null + return (
-
- Status:{' '} - {status} · totaal{' '} - {rows.length} entries -
- + +
+ - {rows.length === 0 ? ( + {filteredRows.length === 0 ? ( - ) : ( - rows.map((row, idx) => ( - + filteredRows.map((row, idx) => ( + + + - @@ -102,3 +221,163 @@ export function DebugRealtimeClient() { ) } + +function ControlBar({ + status, + stats, + sinceLastEvent, + filterType, + setFilterType, + filterEntity, + setFilterEntity, + onEmit, + onReset, +}: { + status: 'connecting' | 'open' | 'closed' | 'error' + stats: Stats + sinceLastEvent: number | null + filterType: 'all' | RowType + setFilterType: (t: 'all' | RowType) => void + filterEntity: 'all' | 'task' | 'story' | 'debug' + setFilterEntity: (e: 'all' | 'task' | 'story' | 'debug') => void + onEmit: () => void + onReset: () => void +}) { + const heartbeatStale = sinceLastEvent !== null && sinceLastEvent > HEARTBEAT_WARN_MS + return ( +
+
+ + + + + 0 + ? `${(stats.largestGapMs / 1000).toFixed(1)}s` + : '—' + } + /> + +
+
+ + {stats.lastEmitResult && ( + {stats.lastEmitResult} + )} + + filter: + + +
+
+ ) +} + +function Stat({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function statusColor(status: 'connecting' | 'open' | 'closed' | 'error') { + switch (status) { + case 'open': + return 'green' + case 'error': + return 'red' + case 'closed': + return 'gray' + default: + return 'orange' + } +} + +function typeColor(type: RowType) { + switch (type) { + case 'message': + return '#0070cc' + case 'ready': + return 'green' + case 'error': + return 'red' + case 'open': + return '#888' + case 'close': + return '#888' + } +} + +function safeParse(raw: string): Record | undefined { + try { + return JSON.parse(raw) as Record + } catch { + return undefined + } +}
received_at typeentity / op payload
- Wachten op events… trigger een mutatie via UI of script. + + Wachten op events… trigger een mutatie via UI / script of klik "emit test event".
- {row.receivedAt} + {new Date(row.receivedAt).toISOString()} + + {row.type} + + {row.parsed?.entity ? ( + <> + {row.parsed.entity as string} + {row.parsed.op ? ` / ${row.parsed.op as string}` : ''} + {row.parsed.debug ? ' [debug]' : ''} + + ) : ( + '—' + )} {row.type} {row.raw}