feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Replace the layer-based (Infrastructure/Governance/Cognition) browsing tabs
with a synthesized category taxonomy built from the ontology's finer-grained
`domain` field, since layer lumped unrelated entity types (an LXC and a DNS
record and a storage volume) into one bucket. Network and Fleet each span
two domains, so the table view now fans out per-domain fetches and merges,
while the graph view maps domain->category client-side. Also carries over
several detail-panel polish items (Tasks-not-raw-executions, slug URL
encoding, MultiSelectFilter) from earlier in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:53:15 +02:00
parent f8e03806aa
commit 35c54ceef5
8 changed files with 465 additions and 168 deletions

View File

@@ -8,7 +8,7 @@
fetchMetrics,
fetchEntityEvents,
fetchEntitySignals,
fetchEntityExecutions,
fetchEntityTasks,
fetchEntityKnowledge,
fetchChecksForTarget,
fetchAgentActivity,
@@ -21,13 +21,13 @@
type Relationship,
type MetricSeries,
type Signal,
type Execution,
type EntityTask,
type KnowledgeHit,
type Check,
type AgentActivity,
type AuditEntry
} from '$lib/api'
import { relativeTime } from '$lib/utils'
import { relativeTime, truncateMiddle } from '$lib/utils'
import type { OikosEvent } from '$lib/stores/events'
import DetailSection from '$lib/components/DetailSection.svelte'
import { Badge } from '$lib/components/ui/badge'
@@ -42,7 +42,7 @@
let metrics = $state<MetricSeries[]>([])
let events = $state<OikosEvent[]>([])
let signals = $state<Signal[]>([])
let executions = $state<Execution[]>([])
let tasks = $state<EntityTask[]>([])
let knowledge = $state<KnowledgeHit[]>([])
let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
@@ -58,12 +58,12 @@
loading = false
return
}
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
const [graphView, m, ev, sig, tk, kh, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
fetchEntityExecutions(entity.id),
fetchEntityTasks(entity),
fetchEntityKnowledge(entity.id),
fetchChecksForTarget(entity.slug),
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
@@ -73,7 +73,7 @@
metrics = m
events = ev
signals = sig
executions = exec
tasks = tk
knowledge = kh
checks = ch
agentActivity = aa
@@ -174,33 +174,113 @@
toast.error('Failed to update check')
}
}
// Attributes are freeform (no attribute_schema on most entity types), so
// the generic key/value list was truncating anything long — including a
// document's whole changelog — to an unreadable single line. Recognize a
// few common shapes and render them properly instead of hiding them.
interface ChangelogEntry {
date?: string
title?: string
body?: string
}
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
function isChangelog(value: unknown): value is ChangelogEntry[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
)
}
function isFlatObject(value: unknown): value is Record<string, unknown> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
)
}
type AttributeRow =
| { key: string; kind: 'long-text'; value: string }
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
| { key: string; kind: 'simple'; value: unknown }
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
return Object.entries(attrs).map(([key, value]): AttributeRow => {
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
return { key, kind: 'long-text', value }
}
if (isChangelog(value)) return { key, kind: 'changelog', value }
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
return { key, kind: 'simple', value }
})
}
</script>
<div class="flex h-full flex-col gap-3 overflow-y-auto p-4 md:p-6">
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
{#if loading}
<Skeleton class="h-8 w-48" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-6 w-48" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
{:else if !entity}
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
{:else}
<div class="flex flex-wrap items-center gap-2">
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
<Badge variant="outline">{entity.type}</Badge>
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
{#if entity.health}
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{/if}
</div>
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
<DetailSection title="Monitoring" count={checks.length} defaultOpen={checks.length > 0}>
<div class="flex flex-col gap-1.5">
{#snippet detailsContent()}
<dl class="flex flex-col gap-1 text-xs">
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Type</dt>
<dd><Badge variant="outline">{entity.type}</Badge></dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">State</dt>
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground"></span>{/if}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Health</dt>
<dd>
{#if entity.health}
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
{entity.health} · checked {relativeTime(entity.last_check_at)}
</span>
{:else}
<span class="text-muted-foreground">not monitored</span>
{/if}
</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Version</dt>
<dd>{entity.version}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b pb-1">
<dt class="shrink-0 text-muted-foreground">Created</dt>
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
</div>
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
<dt class="shrink-0 text-muted-foreground">Updated</dt>
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
</div>
{#if entity.maintenance_until}
<div class="flex items-center justify-between gap-3">
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
</div>
{/if}
</dl>
{/snippet}
{#snippet monitoringContent()}
<div class="flex flex-col gap-1">
{#each checks as check (check.id)}
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
<div class="flex items-center gap-2">
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
<span class="text-muted-foreground">every {check.interval_s}s</span>
@@ -218,46 +298,85 @@
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Attributes" count={Object.keys(entity.attributes ?? {}).length} defaultOpen={!!entity.attributes && Object.keys(entity.attributes).length > 0}>
{#snippet attributesContent()}
{#if entity.attributes && Object.keys(entity.attributes).length}
<dl class="flex flex-col gap-1.5 text-xs">
{#each Object.entries(entity.attributes) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
</div>
{@const rows = classifyAttributes(entity.attributes)}
<div class="flex flex-col gap-2 text-xs">
{#each rows as row (row.key)}
{#if row.kind === 'long-text'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key}</p>
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
</div>
{:else if row.kind === 'changelog'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
<div class="flex flex-col gap-1">
{#each row.value as entry}
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
<div class="flex items-baseline gap-1.5">
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
</div>
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
</div>
{/each}
</div>
</div>
{:else if row.kind === 'flat-object'}
<div class="flex flex-col gap-0.5">
<p class="font-mono text-muted-foreground">{row.key}</p>
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
{#each Object.entries(row.value) as [subKey, subValue]}
<div class="flex items-start justify-between gap-3">
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
</div>
{/each}
</dl>
</div>
{:else}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
<dd class="min-w-0 flex-1 break-words text-right">
{#if row.value !== null && typeof row.value === 'object'}
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
{:else}
{String(row.value)}
{/if}
</dd>
</div>
{/if}
{/each}
</dl>
</div>
{:else}
<p class="text-xs text-muted-foreground">No attributes.</p>
{/if}
</DetailSection>
{/snippet}
<DetailSection title="Relations" count={relations.length} defaultOpen={relations.length > 0}>
{#snippet relationsContent()}
<div class="flex flex-col gap-1">
{#each relations as rel}
<div class="flex items-center gap-1 font-mono text-xs">
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
{#if onSelectEntity}
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.source)}>{rel.source}</button>
<span class="text-muted-foreground">{rel.type}</span>
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.target)}>{rel.target}</button>
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
{:else}
<span>{rel.source}</span>
<span class="text-muted-foreground">{rel.type}</span>
<span>{rel.target}</span>
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
<span class="shrink-0 text-muted-foreground">{rel.type}</span>
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">No direct relations.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Metrics" count={metrics.length} defaultOpen={metrics.length > 0}>
{#snippet metricsContent()}
{#if metrics.length}
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
{#each metrics as series (series.metric)}
@@ -270,12 +389,12 @@
{:else}
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
{/if}
</DetailSection>
{/snippet}
<DetailSection title="Signals" count={signals.length} defaultOpen={signals.length > 0}>
<div class="flex flex-col gap-2">
{#snippet signalsContent()}
<div class="flex flex-col gap-1.5">
{#each signals as signal (signal.id)}
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
<div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span>{signal.kind}</span>
<div class="flex items-center gap-1">
@@ -314,22 +433,35 @@
<p class="text-xs text-muted-foreground">None.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Executions" count={executions.length} defaultOpen={executions.length > 0}>
{#snippet tasksContent()}
<div class="flex flex-col gap-1">
{#each executions as execution (execution.id)}
<div class="flex items-center justify-between text-xs">
<span>{execution.action}</span>
<Badge variant="outline">{execution.status}</Badge>
{#each tasks as { task, executionCount } (task.id)}
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
{#if onSelectEntity}
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
{title}
</button>
{:else}
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
{/if}
<div class="flex shrink-0 items-center gap-1">
{#if outcome}
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
{/if}
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">None.</p>
<p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Knowledge" count={knowledge.length} defaultOpen={knowledge.length > 0}>
{#snippet knowledgeContent()}
<div class="flex flex-col gap-1">
{#each knowledge as hit (hit.id)}
<div class="text-xs">
@@ -339,10 +471,10 @@
<p class="text-xs text-muted-foreground">None linked.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Recent events" count={events.length} defaultOpen={events.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#snippet eventsContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each events as ev (ev.id)}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
@@ -352,12 +484,12 @@
<p class="text-xs text-muted-foreground">No events yet.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Agent activity" count={agentActivity.length} defaultOpen={agentActivity.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#snippet agentActivityContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each agentActivity as activity (activity.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
@@ -370,12 +502,12 @@
<p class="text-xs text-muted-foreground">No agent activity.</p>
{/each}
</div>
</DetailSection>
{/snippet}
<DetailSection title="Audit trail" count={auditEntries.length} defaultOpen={auditEntries.length > 0}>
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{#snippet auditContent()}
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
{#each auditEntries as entry (entry.id)}
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
<Badge variant="outline">{entry.actor_type}</Badge>
@@ -386,6 +518,26 @@
<p class="text-xs text-muted-foreground">No audit entries.</p>
{/each}
</div>
</DetailSection>
{/snippet}
{@const sections = [
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
{#each sections as section (section.key)}
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
{@render section.content()}
</DetailSection>
{/each}
{/if}
</div>