Problem: requested visual alignment with the shadcn-svelte dashboard-01
reference block (shadcn-svelte.com/blocks#dashboard-01) — the app's
sidebar/header shell and Overview stat cards looked plain by comparison.
Change: pulled the actual reference source (app-sidebar.svelte,
nav-main.svelte, site-header.svelte, section-cards.svelte from
huntabyte/shadcn-svelte) rather than approximating from screenshots.
- App.svelte: Sidebar.Root now uses variant="inset" (the floating,
rounded, shadowed content panel — already fully built into the
existing Sidebar.Inset component via peer-data selectors, just never
enabled). Brand mark is now a proper Sidebar.MenuButton matching the
reference's padding/hover treatment; "New chat" uses the reference's
primary-colored button styling. Header matches the reference exactly:
h-(--header-height) (48px, was 44px), vertical separator after the
sidebar trigger, right-aligned actions group.
- Overview.svelte: stat cards rebuilt to match section-cards.svelte —
gradient background, Card.Action badge, Card.Footer with a bold line
+ muted context line, tabular-nums, responsive @container grid
(1/2/4 columns). Deliberately did NOT copy the reference's fake
trend-percentage badges (Oikos doesn't track historical trends, and
this project's whole thrust has been eliminating dishonest UI state —
see 279549c). Badges instead reflect real current-state signals
(healthy/degraded/down, clear/needs-review) computed from the actual
dashboard summary.
- EntityDetailContent.svelte + Entities/Signals/Ops/Events/Agent/
Audit/Knowledge pages: normalized root padding to p-4 md:p-6 (was a
flat p-6) to match the reference's responsive py-4 md:py-6 convention.
Risk: reversible_low (UI-only, no data or behavior changes).
Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings, same as prior commits). go build/vet
clean (backend untouched, sanity check only). Manually verified in the
browser preview at 1400px: inset sidebar's margin/rounded-corner/shadow
classes confirmed applied via computed styles; Overview cards render
with real live numbers from the now-fixed dashboard summary endpoint;
Signals/Ops pages confirmed visually consistent with the new spacing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
7.1 KiB
Svelte
178 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
import { fetchEvents } from '$lib/api'
|
|
import { liveEvents, connectionState, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
|
import * as Table from '$lib/components/ui/table'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import { Input } from '$lib/components/ui/input'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
|
|
let history = $state<OikosEvent[]>([])
|
|
let paused = $state(false)
|
|
let typeFilter = $state('')
|
|
let severityFilter = $state('')
|
|
let groupByCorrelation = $state(false)
|
|
let expandedCorrelations = $state<Set<string>>(new Set())
|
|
|
|
async function loadHistory() {
|
|
history = await fetchEvents({ type: typeFilter || undefined, severity: severityFilter || undefined })
|
|
}
|
|
|
|
onMount(() => {
|
|
loadHistory()
|
|
const unsubscribe = subscribeEvents()
|
|
return unsubscribe
|
|
})
|
|
|
|
const feed = $derived.by(() => {
|
|
if (paused) return history
|
|
const seen = new Set(history.map((e) => e.id))
|
|
const merged = [...$liveEvents.filter((e) => !seen.has(e.id)), ...history]
|
|
return merged
|
|
.filter((e) => (!typeFilter || e.type.startsWith(typeFilter)) && (!severityFilter || e.severity === severityFilter))
|
|
.slice(0, 300)
|
|
})
|
|
|
|
const clustered = $derived.by(() => {
|
|
if (!groupByCorrelation) return null
|
|
const groups: { corr: string | null; events: OikosEvent[]; latest: number }[] = []
|
|
const seen = new Map<string | null, OikosEvent[]>()
|
|
for (const ev of feed) {
|
|
const key = ev.correlation_id ?? null
|
|
if (!seen.has(key)) seen.set(key, [])
|
|
seen.get(key)!.push(ev)
|
|
}
|
|
for (const [corr, events] of seen) {
|
|
groups.push({ corr, events, latest: Math.max(...events.map((e) => e.id)) })
|
|
}
|
|
groups.sort((a, b) => b.latest - a.latest)
|
|
return groups
|
|
})
|
|
|
|
function toggleCorrelation(corr: string | null) {
|
|
const key = corr ?? '__none'
|
|
expandedCorrelations = new Set(expandedCorrelations)
|
|
if (expandedCorrelations.has(key)) {
|
|
expandedCorrelations.delete(key)
|
|
} else {
|
|
expandedCorrelations.add(key)
|
|
}
|
|
}
|
|
|
|
function correlationLabel(corr: string | null): string {
|
|
if (!corr) return 'ungrouped'
|
|
return corr.slice(0, 12)
|
|
}
|
|
|
|
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
|
if (sev === 'critical') return 'destructive'
|
|
if (sev === 'warning') return 'secondary'
|
|
return 'default'
|
|
}
|
|
|
|
function mostSevere(events: OikosEvent[]): 'info' | 'warning' | 'critical' {
|
|
if (events.some((e) => e.severity === 'critical')) return 'critical'
|
|
if (events.some((e) => e.severity === 'warning')) return 'warning'
|
|
return 'info'
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
<div class="flex items-center justify-between">
|
|
<h1 class="text-lg font-semibold">Live event feed</h1>
|
|
<span class="text-xs text-muted-foreground">
|
|
stream: {$connectionState}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Input placeholder="Type prefix (e.g. entity.)" bind:value={typeFilter} class="max-w-xs" onchange={loadHistory} />
|
|
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" onchange={loadHistory} />
|
|
<Button variant={paused ? 'default' : 'outline'} onclick={() => (paused = !paused)}>
|
|
{paused ? 'Resume' : 'Pause'}
|
|
</Button>
|
|
<Button variant={groupByCorrelation ? 'default' : 'outline'} onclick={() => (groupByCorrelation = !groupByCorrelation)}>
|
|
Groups
|
|
</Button>
|
|
<Button variant="outline" onclick={loadHistory}>Refresh</Button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-hidden rounded-md border">
|
|
<ScrollArea class="h-full">
|
|
{#if groupByCorrelation && clustered}
|
|
<div class="flex flex-col">
|
|
{#each clustered as group (group.corr ?? '__none')}
|
|
{@const key = group.corr ?? '__none'}
|
|
{@const isExpanded = expandedCorrelations.has(key)}
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-2 border-b px-4 py-2 text-left text-xs hover:bg-muted/50"
|
|
onclick={() => toggleCorrelation(group.corr)}
|
|
>
|
|
{#if isExpanded}
|
|
<ChevronDownIcon class="size-3 text-muted-foreground" />
|
|
{:else}
|
|
<ChevronRightIcon class="size-3 text-muted-foreground" />
|
|
{/if}
|
|
<Badge variant={severityVariant(mostSevere(group.events))} class="shrink-0"
|
|
>{mostSevere(group.events)}</Badge
|
|
>
|
|
<span class="font-mono">{correlationLabel(group.corr)}</span>
|
|
<span class="text-muted-foreground">{group.events.length} events</span>
|
|
<span class="truncate text-muted-foreground">{group.events[0]?.type ?? ''}</span>
|
|
<span class="grow"></span>
|
|
<span class="text-muted-foreground">{new Date(group.events[0]?.ts ?? '').toLocaleTimeString()}</span>
|
|
</button>
|
|
{#if isExpanded}
|
|
{#each group.events as ev (ev.id)}
|
|
<div class="flex items-center gap-3 border-b py-1 pl-10 pr-4 text-xs">
|
|
<span class="w-20 shrink-0 font-mono text-muted-foreground"
|
|
>{new Date(ev.ts).toLocaleTimeString()}</span
|
|
>
|
|
<Badge variant={severityVariant(ev.severity)} class="shrink-0">{ev.severity}</Badge>
|
|
<span class="font-mono">{ev.type}</span>
|
|
<span class="truncate text-muted-foreground">{ev.source}</span>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<Table.Root>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.Head class="w-32">Time</Table.Head>
|
|
<Table.Head class="w-24">Severity</Table.Head>
|
|
<Table.Head>Type</Table.Head>
|
|
<Table.Head>Source</Table.Head>
|
|
<Table.Head>Correlation</Table.Head>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{#each feed as ev (ev.id)}
|
|
<Table.Row>
|
|
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
>{new Date(ev.ts).toLocaleTimeString()}</Table.Cell
|
|
>
|
|
<Table.Cell><Badge variant={severityVariant(ev.severity)}>{ev.severity}</Badge></Table.Cell>
|
|
<Table.Cell class="font-mono text-xs">{ev.type}</Table.Cell>
|
|
<Table.Cell class="text-xs text-muted-foreground">{ev.source}</Table.Cell>
|
|
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
>{ev.correlation_id ?? '—'}</Table.Cell
|
|
>
|
|
</Table.Row>
|
|
{:else}
|
|
<Table.Row>
|
|
<Table.Cell colspan={5} class="text-center text-muted-foreground">No events yet.</Table.Cell>
|
|
</Table.Row>
|
|
{/each}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
{/if}
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|