feat(web): browse Knowledge Base by mixed Network/Fleet/Services/Storage/Identity/Knowledge categories
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user