feat(web): unify Knowledge Base filtering into one type multiselect

Replace the Fleet/Network/Identity/Knowledge category tabs (which
scoped entity fetches server-side) with a single "Types" multiselect
shared by both the table and graph views — both now fetch the whole
entity set (paginated via the new fetchAllEntities) and filter
client-side, defaulting to fleet's types. Table and graph also share
one search/highlight field instead of two separately-labeled ones.

Along the way, fixed a real bug the wider entity set exposed: the
treegrid's parent/child grouping fired one fetchGraph call per
candidate root entity, fine for the old ~50-entity fleet scope but an
ERR_INSUFFICIENT_RESOURCES flood once scoped to the full ~1700-entity
set. Replaced with a single whole-graph fetch, deriving parent/child
pairs from its edges client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:00:13 +02:00
parent 6051fb4845
commit e28e0e9ea3
4 changed files with 191 additions and 263 deletions

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, filtersForCategory, type Category } from '$lib/categories'
import { typeToCategory, type Category } from '$lib/categories'
import { openEntityWindow, wmState } from '$lib/stores/windows'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import NetworkIcon from '@lucide/svelte/icons/share-2'
@@ -23,8 +22,11 @@
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
}
let category = $state<Category>('fleet')
let view = $state<View>(loadView())
// Shared between table (filters rows) and graph (highlights/searches
// nodes) — one search box instead of two differently-labeled ones, since
// both views are asking the same underlying question ("show me X").
let search = $state('')
// Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (EntityDesktop),
// which can have several entities open at once.
@@ -47,34 +49,34 @@
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
})
// ─── table data: fetched here (not inside EntityTable) so the search/type
// ─── entities: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse
// pane, where its width is at the mercy of the divider and it would
// truncate. This also keeps the browse pane header-free, so it and the
// detail pane both start flush under the toolbar and end up the same height.
let tableEntities = $state<Entity[]>([])
let tableLoading = $state(true)
let query = $state('')
let typeFilter = $state('all')
// truncate. Both views now share the same full entity set — there's no
// more per-category server-side scoping, only the client-side type
// multiselect (activeTypes) below, which both the table (row visibility)
// and the graph (node visibility) read from.
let allEntities = $state<Entity[]>([])
let entitiesLoading = $state(true)
let showInactive = $state(false)
// child entity slug -> parent entity slug, derived from the ontology graph
// (see loadFleetGrouping). Only populated for the fleet category — feeds
// EntityTable's treegrid grouping, nesting e.g.
// cluster -> host -> lxc -> service, or storage-pool -> volume -> dataset.
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
// over the whole entity set so the hierarchy doesn't reshuffle as the type
// filter is toggled (EntityTable falls back a filtered-out parent's
// children to top-level rather than dropping them).
let childToParent = $state<Map<string, string> | null>(null)
let ontologyCache: Promise<Ontology> | null = null
function isOrDescendsFrom(byName: Map<string, EntityType>, typeName: string, ancestor: string): boolean {
if (typeName === ancestor) return true
let t = byName.get(typeName)
for (let depth = 0; t?.parent_type && depth < 10; depth++) {
if (t.parent_type === ancestor) return true
t = byName.get(t.parent_type)
}
return false
let ontologyPromise: Promise<Ontology> | null = null
function getOntology(): Promise<Ontology> {
ontologyPromise ??= fetchOntology()
return ontologyPromise
}
// type -> browsing category (see categories.ts), used only to seed the
// type multiselect's default selection ("fleet") — not to scope any fetch.
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
// Distance from the ontology's abstract root ("entity") down to typeName —
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
// specificity score: a relationship whose parent-side type is a generic
@@ -97,9 +99,9 @@
// parent (e.g. many hosts are located-at one site). many-to-many
// relationships (mounts, stores-on, backs-up-to, ...) have no single
// parent, so they're excluded from tree nesting. A candidate parent that
// isn't actually part of the fleet set being browsed (e.g. `cluster`,
// filtered out below) is dropped rather than kept as a dangling pointer —
// that's also what lets `located-at` surface as a host's parent instead of
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
// out below) is dropped rather than kept as a dangling pointer — that's
// also what lets `located-at` surface as a host's parent instead of
// `member-of` without any special-cased priority: with cluster absent,
// member-of simply has nothing valid to point at. An entity can still be
// the child end of several different *remaining* relationship types at
@@ -107,81 +109,82 @@
// repo) — only one can win as its tree parent, so ties go to the more
// specific relationship (see typeDepth) rather than whichever was fetched
// last.
async function loadFleetGrouping(fleetEntities: Entity[]): Promise<Map<string, string>> {
ontologyCache ??= fetchOntology()
const { entityTypes, relationshipTypes } = await ontologyCache
async function loadGrouping(entities: Entity[]): Promise<Map<string, string>> {
const { entityTypes, relationshipTypes } = await getOntology()
const byName = new Map(entityTypes.map((t) => [t.name, t]))
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const fleetSlugs = new Set(fleetEntities.map((e) => e.slug))
const slugs = new Set(entities.map((e) => e.slug))
// blast_radius only walks source -> target, so an entity only surfaces a
// relationship if it can be that relationship's source.
const roots = fleetEntities.filter((e) =>
hierRels.some((rt) => isOrDescendsFrom(byName, e.type, rt.source_type))
)
const pairs = await Promise.all(
roots.map(async (root) => {
const g = await fetchGraph({ root: root.slug, depth: 1, relType: relTypeNames })
return (g?.edges ?? [])
.filter((edge) => edge.source === root.slug && cardinalityByType.has(edge.type))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // root is the child; target is the "one" (parent)
: [edge.target, edge.source] // root is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
.filter(({ parent }) => fleetSlugs.has(parent))
// One whole-graph fetch instead of one rooted fetch per candidate parent
// — now that grouping runs over the entire entity set rather than a
// ~50-entity category, firing a request per entity blew past the
// browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
const g = await fetchGraph({ relType: relTypeNames })
const pairs = (g?.edges ?? [])
.filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // source is the child; target is the "one" (parent)
: [edge.target, edge.source] // source is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
)
const best = new Map<string, { parent: string; weight: number }>()
for (const { child, parent, weight } of pairs.flat()) {
for (const { child, parent, weight } of pairs) {
const current = best.get(child)
if (!current || weight > current.weight) best.set(child, { parent, weight })
}
return new Map([...best].map(([child, { parent }]) => [child, parent]))
}
async function loadTable() {
tableLoading = true
const filterSets = filtersForCategory(category)
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
// cluster entities aren't shown in Fleet browsing — with them absent, a
// host's `member-of` edge has no valid parent to point at, so
// `located-at` (site) is the only remaining candidate and wins the
// tree-parent tie-break without a hardcoded relationship priority (see
// loadFleetGrouping).
const fetched = results.flat().filter((e) => e.type !== 'cluster')
childToParent = category === 'fleet' ? await loadFleetGrouping(fetched) : null
tableEntities = fetched
tableLoading = false
async function loadEntities() {
entitiesLoading = true
// cluster entities are dropped so a host's `member-of` edge has no valid
// parent to point at, leaving `located-at` (site) as the only remaining
// tree-parent candidate (see loadGrouping).
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
childToParent = await loadGrouping(fetched)
allEntities = fetched
entitiesLoading = false
}
onMount(() => {
loadEntities()
getOntology().then((o) => {
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
})
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
if (view !== 'table') return
category
loadTable()
})
$effect(() => {
const ev = $liveEvents[0]
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return
loadTable()
if (!ev || !ev.type.startsWith('entity.')) return
loadEntities()
})
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
// Shared show/hide-by-type filter — governs both the table's row
// visibility and the graph's node visibility. Seeded once (not
// re-derived) to "fleet" types as soon as both the entity set and the
// ontology's type->category map are loaded, so it doesn't clobber the
// user's own toggles on a later reload.
let activeTypes = $state<Set<string>>(new Set())
let typesSeeded = false
$effect(() => {
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
typesSeeded = true
})
const tableTypes = $derived(Array.from(new Set(tableEntities.map((e) => e.type))).sort())
const filteredEntities = $derived.by(() => {
const q = query.trim().toLowerCase()
return tableEntities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
const q = search.trim().toLowerCase()
return allEntities.filter((e) => {
if (!activeTypes.has(e.type)) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
// entities with no tracked lifecycle state (state is null) aren't
// "destroyed or inactive" — only hide ones whose tracked state has
@@ -196,12 +199,10 @@
// width instead of being squeezed by the resizable browse pane.
let graphRoot = $state('')
let graphDepth = $state(2)
let graphSearch = $state('')
let graphReloadToken = $state(0)
let graphResetToken = $state(0)
let graphActiveNodeTypes = $state<Set<string>>(new Set())
let graphActiveRelTypes = $state<Set<string>>(new Set())
let graphInfo = $state<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
function commitGraphQuery() {
graphReloadToken++
@@ -209,7 +210,7 @@
function resetGraph() {
graphRoot = ''
graphSearch = ''
search = ''
graphResetToken++
}
@@ -219,97 +220,76 @@
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: category perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3">
<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>
<!-- single toolbar row: search + type filter are shared by both views
(one multiselect instead of a category tab, a single-select "All
types" dropdown, and a separate graph node-type toggle), the rest is
view-specific, and the graph/table switch sits inline with the rest
instead of floating in its own row. -->
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
{#if view === 'table'}
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
{:else}
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
{/if}
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button
variant={view === 'graph' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('graph')}
title="Graph view"
aria-label="Graph view"
>
<NetworkIcon class="mr-1 size-3.5" /> Graph
<NetworkIcon class="size-3.5" />
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('table')}
title="Table view"
aria-label="Table view"
>
<TableIcon class="mr-1 size-3.5" /> Table
<TableIcon class="size-3.5" />
</Button>
</div>
</div>
{#if view === 'table'}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="h-8 max-w-xs text-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="h-8 w-40 text-xs">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each tableTypes as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-44 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-16 text-xs" onchange={commitGraphQuery} />
<Input placeholder="Search / highlight…" bind:value={graphSearch} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<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}
<!-- browse pane — selecting an entity opens it in a floating window
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{category}
selectedSlug={lastOpened}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
{search}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
activeNodeTypes={activeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
{/if}
</div>
</div>