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

@@ -1,155 +1,212 @@
<script lang="ts">
import Chat from '$lib/../pages/Chat.svelte'
import Sessions from '$lib/../pages/Sessions.svelte'
import Chat from './pages/Chat.svelte'
import Sessions from './pages/Sessions.svelte'
import Overview from './pages/Overview.svelte'
import Entities from './pages/Entities.svelte'
import Events from './pages/Events.svelte'
import Ops from './pages/Ops.svelte'
import Signals from './pages/Signals.svelte'
import Graph from './pages/Graph.svelte'
import EntityDetail from './pages/EntityDetail.svelte'
import Agent from './pages/Agent.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Audit from './pages/Audit.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { onMount } from 'svelte'
import { slide } from 'svelte/transition'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import ListIcon from '@lucide/svelte/icons/list'
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
import DatabaseIcon from '@lucide/svelte/icons/database'
import ActivityIcon from '@lucide/svelte/icons/activity'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import BotIcon from '@lucide/svelte/icons/bot'
import SearchIcon from '@lucide/svelte/icons/search'
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
let page = $state('chat')
let routeParam = $state('')
let drawerOpen = $state(false)
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
onMount(() => {
function sync() {
page = location.hash.slice(2) || 'chat'
const path = location.hash.slice(2) || 'chat'
const [head, ...rest] = path.split('/')
page = head || 'chat'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
const unsubscribeCtx = subscribeContext()
return () => {
window.removeEventListener('hashchange', sync)
unsubscribeCtx()
}
})
function navigate(p: string) {
location.hash = '#/' + p
}
const navItems = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'events', label: 'Events', icon: ActivityIcon },
{ id: 'agent', label: 'Agent', icon: BotIcon },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon },
{ id: 'sessions', label: 'Sessions', icon: ListIcon }
]
</script>
<div class="app">
<nav class="sidebar">
<div class="logo">Oikos</div>
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
<span class="nav-icon"></span>
<span>New</span>
</button>
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
<span class="nav-icon">💬</span>
<span>Chat</span>
</button>
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
<span class="nav-icon">📋</span>
<span>Sessions</span>
</button>
<Toaster />
<div class="spacer"></div>
<Sidebar.Provider>
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<div class="flex items-center justify-between px-1">
<span class="text-sm font-bold text-primary">Oikos</span>
</div>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => { newChat(); navigate('chat') }} tooltipContent="New chat">
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
<span>New chat</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
Chat {drawerOpen ? '▼' : '▲'}
</button>
</nav>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={page === 'chat'} onclick={() => navigate('chat')} tooltipContent="Chat">
{#snippet child({ props })}
<button {...props}>
<MessageSquareIcon />
<span>Chat</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
{#snippet child({ props })}
<button {...props}>
<item.icon />
<span>{item.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
{#if item.badge?.()}
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<main class="main">
{#if page === 'chat'}
<Chat />
{:else if page === 'sessions'}
<Sessions />
{:else}
<Chat />
{/if}
</main>
<Sidebar.Footer>
<Button variant="ghost" size="sm" class="justify-start gap-2" onclick={() => (drawerOpen = true)}>
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
{#if drawerOpen}
<aside class="drawer" transition:slide={{ axis: 'x' }}>
<Chat />
</aside>
{/if}
</div>
<Sidebar.Inset class="h-svh min-h-0">
<header class="flex h-11 shrink-0 items-center gap-3 border-b px-3">
<Sidebar.Trigger />
<span class="text-sm font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="flex-1"></div>
{#if $summary}
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
</div>
{#if approvalsPending}
<button type="button" onclick={() => navigate('ops')}>
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
</button>
{/if}
{#if openSignals}
<button type="button" onclick={() => navigate('signals')}>
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
</button>
{/if}
{/if}
<span
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
title="event stream: {$connectionState}"
></span>
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'entities'}
<Entities />
{:else if page === 'graph'}
<Graph />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
<Ops />
{:else if page === 'signals'}
<Signals />
{:else if page === 'events'}
<Events />
{:else if page === 'sessions'}
<Sessions />
{:else if page === 'agent'}
<Agent />
{:else if page === 'knowledge'}
<Knowledge />
{:else if page === 'audit'}
<Audit />
{:else}
<Chat />
{/if}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<style>
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 56px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0;
gap: 0.5rem;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-blue);
margin-bottom: 0.5rem;
user-select: none;
}
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.5rem;
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
border-radius: 6px;
width: 44px;
transition: background 0.15s, color 0.15s;
}
.nav-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.nav-btn.active {
background: var(--bg-active);
color: var(--accent-blue);
}
.nav-icon {
font-size: 1rem;
}
.spacer {
flex: 1;
}
.drawer-toggle {
border: none;
background: none;
color: var(--text-muted);
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
padding: 0.5rem;
}
.drawer-toggle:hover {
color: var(--text);
}
.main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.drawer {
width: 380px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>
<Sheet.Root bind:open={drawerOpen}>
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
<Sheet.Header class="sr-only">
<Sheet.Title>Nomos chat</Sheet.Title>
<Sheet.Description>Persistent chat drawer</Sheet.Description>
</Sheet.Header>
<div class="flex h-full flex-col">
<Chat showRail={false} />
</div>
</Sheet.Content>
</Sheet.Root>