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

@@ -5,30 +5,24 @@
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import * as Tabs from '$lib/components/ui/tabs'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, domainsForCategory, type Category } from '$lib/categories'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import * as Select from '$lib/components/ui/select'
import NetworkIcon from '@lucide/svelte/icons/share-2'
import TableIcon from '@lucide/svelte/icons/table-2'
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
import XIcon from '@lucide/svelte/icons/x'
type Layer = 'infrastructure' | 'governance' | 'cognition'
type View = 'graph' | 'table'
const layers: { id: Layer; label: string }[] = [
{ id: 'infrastructure', label: 'Infrastructure' },
{ id: 'governance', label: 'Governance' },
{ id: 'cognition', label: 'Cognition' }
]
function loadView(): View {
if (typeof localStorage === 'undefined') return 'graph'
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
}
let layer = $state<Layer>('infrastructure')
let category = $state<Category>('fleet')
let view = $state<View>(loadView())
let selectedSlug = $state<string | null>(null)
@@ -37,7 +31,7 @@
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-kb-view', v)
}
function select(slug: string) {
function select(slug: string | null) {
selectedSlug = slug
}
@@ -53,7 +47,9 @@
async function loadTable() {
tableLoading = true
tableEntities = await fetchEntities({ layer })
const domains = domainsForCategory(category)
const results = await Promise.all(domains.map((domain) => fetchEntities({ domain })))
tableEntities = results.flat()
tableLoading = false
}
@@ -64,7 +60,7 @@
$effect(() => {
if (view !== 'table') return
layer
category
loadTable()
})
@@ -106,11 +102,8 @@
graphResetToken++
}
function toggleSet(set: Set<string>, value: string): Set<string> {
const next = new Set(set)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
function relColorFor(type: string): string {
return graphInfo.relColors.get(type) ?? '#30363d'
}
// ─── resizable browse/detail split (pattern from Chat.svelte) ─────────
@@ -145,15 +138,20 @@
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: layer perspective + view toggle -->
<!-- toolbar: category perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3">
<Tabs.Root value={layer} onValueChange={(v) => (layer = v as Layer)}>
<Tabs.List class="h-8">
{#each layers as l}
<Tabs.Trigger value={l.id} class="text-xs">{l.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
<div class="inline-flex overflow-hidden rounded-md border">
{#each categories as c}
<Button
variant={category === c.id ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
onclick={() => (category = c.id)}
>
{c.label}
</Button>
{/each}
</div>
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button
@@ -200,42 +198,12 @@
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Nodes" options={graphInfo.allNodeTypes} bind:selected={graphActiveNodeTypes} />
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
</div>
{#if graphInfo.allNodeTypes.length || graphInfo.allRelTypes.length}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
{#if graphInfo.allNodeTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
{#each graphInfo.allNodeTypes as type}
<button type="button" onclick={() => (graphActiveNodeTypes = toggleSet(graphActiveNodeTypes, type))}>
<Badge variant={graphActiveNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
</button>
{/each}
</div>
{/if}
{#if graphInfo.allRelTypes.length}
<div class="flex flex-wrap items-center gap-1">
<span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
{#each graphInfo.allRelTypes as type}
{@const color = graphInfo.relColors.get(type) ?? '#30363d'}
<button type="button" onclick={() => (graphActiveRelTypes = toggleSet(graphActiveRelTypes, type))}>
<Badge
variant="outline"
class="h-5 cursor-pointer px-1.5 text-[10px] {graphActiveRelTypes.has(type) ? '' : 'opacity-35'}"
style="border-color: {color}; color: {color};"
>
{type}
</Badge>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
<!-- resizable browse | detail split -->
@@ -243,7 +211,7 @@
<div class="flex min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{layer}
{category}
{selectedSlug}
onSelect={select}
bind:root={graphRoot}
@@ -275,9 +243,16 @@
<div class="flex shrink-0 flex-col overflow-hidden rounded-lg border" style="width: {detailWidth}px">
{#if selectedSlug}
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
<div class="flex shrink-0 items-center justify-end border-b p-1">
<Button variant="ghost" size="icon" class="size-7" onclick={() => select(null)} aria-label="Close detail panel">
<XIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1">
{#key selectedSlug}
<EntityDetailContent slug={selectedSlug} onSelectEntity={select} />
{/key}
</div>
{:else}
<div class="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select an entity to see its detail.