feat(ui): M4 — agent activity, knowledge search, audit, correlation grouping
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Add three new pages completing the control-room web UI:
- Agent activity: polls /agent-activity every 5s, filterable by type/agent
- Knowledge search: FTS over /knowledge/search with snippet + entity links
- Audit trail: browseable audit log with actor/action/entity filters

Enhanced live events page with correlation-id clustering (Groups toggle).
Added fetchAgentActivity/searchKnowledge/fetchAudit to the API client.
11 nav items now cover all planned control-room views.
This commit is contained in:
2026-07-08 17:02:09 +02:00
parent cff05c0768
commit 2908b0a377
162 changed files with 7857 additions and 553 deletions

View File

@@ -0,0 +1,63 @@
import { writable, get } from 'svelte/store'
import { fetchDashboardSummary, fetchApprovals, type DashboardSummary, type Approval } from '$lib/api'
import { liveEvents, subscribeEvents, type OikosEvent } from './events'
// Shared operational context: dashboard summary + pending approvals,
// refreshed on a slow poll and eagerly on relevant SSE events. Ref-counted
// so the poll only runs while something on screen displays it.
export const summary = writable<DashboardSummary | null>(null)
export const pendingApprovals = writable<Approval[]>([])
let refs = 0
let pollTimer: ReturnType<typeof setInterval> | null = null
let unsubscribeSSE: (() => void) | null = null
let unsubscribeStore: (() => void) | null = null
let lastSeenEventId = 0
export async function refreshContext() {
const [s, approvals] = await Promise.all([fetchDashboardSummary(), fetchApprovals('pending')])
if (s) summary.set(s)
pendingApprovals.set(approvals)
}
function onEvent(ev: OikosEvent) {
if (ev.id <= lastSeenEventId) return
lastSeenEventId = ev.id
if (
ev.type.startsWith('approval.') ||
ev.type.startsWith('signal.') ||
ev.type.startsWith('execution.') ||
ev.type === 'health.changed'
) {
refreshContext()
}
}
export function subscribeContext(): () => void {
refs++
if (refs === 1) {
refreshContext()
pollTimer = setInterval(refreshContext, 30000)
unsubscribeSSE = subscribeEvents()
unsubscribeStore = liveEvents.subscribe((events) => {
if (events[0]) onEvent(events[0])
})
}
return () => {
refs--
if (refs === 0) {
if (pollTimer) clearInterval(pollTimer)
pollTimer = null
unsubscribeSSE?.()
unsubscribeSSE = null
unsubscribeStore?.()
unsubscribeStore = null
}
}
}
export function openSignalCount(s: DashboardSummary | null): number {
if (!s) return 0
return Object.values(s.signals_by_severity).reduce((a, b) => a + b, 0)
}

View File

@@ -0,0 +1,57 @@
import { writable } from 'svelte/store'
export interface OikosEvent {
id: number
ts: string
type: string
entity_id?: string | null
severity: 'info' | 'warning' | 'critical'
source: string
data?: unknown
correlation_id?: string | null
}
const MAX_BUFFERED = 200
export const liveEvents = writable<OikosEvent[]>([])
export const connectionState = writable<'connecting' | 'open' | 'closed'>('connecting')
let source: EventSource | null = null
let subscriberCount = 0
function connect() {
if (source) return
connectionState.set('connecting')
// The browser's EventSource sends Last-Event-ID automatically on reconnect.
source = new EventSource('/api/v1/events/stream')
source.onopen = () => connectionState.set('open')
source.onmessage = (ev) => {
try {
const parsed: OikosEvent = JSON.parse(ev.data)
liveEvents.update((events) => [parsed, ...events].slice(0, MAX_BUFFERED))
} catch {
// skip malformed
}
}
source.onerror = () => {
connectionState.set('closed')
}
}
function disconnect() {
source?.close()
source = null
}
// Reference-counted: the stream stays open as long as at least one page subscribes.
export function subscribeEvents(): () => void {
subscriberCount++
if (subscriberCount === 1) connect()
return () => {
subscriberCount--
if (subscriberCount === 0) disconnect()
}
}