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:
@@ -213,6 +213,24 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// The entities endpoint caps at 200/page — the Knowledge Base wants the
|
||||
// whole set (it filters by type/search client-side now instead of scoping
|
||||
// the fetch server-side), so page through via cursor until exhausted.
|
||||
export async function fetchAllEntities(): Promise<Entity[]> {
|
||||
const all: Entity[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: '200' })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const res = await fetchWithAuth(`${API}/entities?${params}`)
|
||||
if (!res.ok) break
|
||||
const data = await res.json()
|
||||
all.push(...(data.items ?? []))
|
||||
cursor = data.next_cursor ?? undefined
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
||||
|
||||
export interface EntityType {
|
||||
|
||||
@@ -3,18 +3,11 @@
|
||||
// which lumps very different things (an LXC and a DNS record and a storage
|
||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||
// `domain` field instead, which already draws these lines; this just
|
||||
// groups the domains into browsing-sized buckets.
|
||||
import type { EntityFilters } from './api'
|
||||
|
||||
// groups the domains into browsing-sized buckets. The Knowledge Base shows
|
||||
// every entity at once now (filtered by the type multiselect, not by a
|
||||
// fetch-time category), but "fleet" still names the default type selection.
|
||||
export type Category = 'network' | 'fleet' | 'identity' | 'knowledge'
|
||||
|
||||
export const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'identity', label: 'Identity' },
|
||||
{ id: 'knowledge', label: 'Knowledge' }
|
||||
]
|
||||
|
||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||
// domain-registration are network-adjacent); `physical`, `software`, and
|
||||
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
|
||||
@@ -49,15 +42,3 @@ export function typeToCategory(type: string, domain: string): Category | undefin
|
||||
if (domain === 'cognition') return undefined
|
||||
return DOMAIN_TO_CATEGORY[domain]
|
||||
}
|
||||
|
||||
// Filter sets to fetch and merge for a category's table view. Most
|
||||
// categories are one or two `domain` values; Knowledge is a handful of
|
||||
// specific `type`s carved out of the (otherwise excluded) cognition domain.
|
||||
export function filtersForCategory(category: Category): EntityFilters[] {
|
||||
if (category === 'knowledge') {
|
||||
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
|
||||
}
|
||||
return Object.entries(DOMAIN_TO_CATEGORY)
|
||||
.filter(([, c]) => c === category)
|
||||
.map(([domain]) => ({ domain }))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { typeToCategory, type Category } from '$lib/categories'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allNodeTypes: string[]
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
@@ -16,7 +14,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
category,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
@@ -24,11 +21,12 @@
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
activeNodeTypes = $bindable(new Set<string>()),
|
||||
// Owned by the parent (shared with the entity table's type filter) —
|
||||
// this graph only reads it to decide what's in focus, never writes it.
|
||||
activeNodeTypes,
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
category: Category
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
@@ -39,7 +37,7 @@
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes?: Set<string>
|
||||
activeNodeTypes: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
@@ -59,19 +57,20 @@
|
||||
type: string
|
||||
}
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — see
|
||||
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
|
||||
// (also covers the per-relationship-type arrow markers below, which were
|
||||
// keyed only by type name and would collide the same way across two
|
||||
// mounted EntityGraph instances).
|
||||
const uid = crypto.randomUUID().slice(0, 8)
|
||||
const dotGridId = `dot-grid-${uid}`
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → browsing category, so the graph can be scoped client-side (the
|
||||
// graph endpoint itself has no category/domain param). Value is undefined
|
||||
// for types deliberately excluded from every category (e.g. execution/
|
||||
// check/task — see categories.ts); the key is still present so inCategory
|
||||
// can tell "excluded on purpose" apart from "not in the ontology at all."
|
||||
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
@@ -101,7 +100,7 @@
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
|
||||
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
@@ -111,46 +110,7 @@
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
// Node belongs to the active category? Types the ontology never returned
|
||||
// at all fall back to visible (so a missing entry never blanks the
|
||||
// graph); types the ontology returned but categories.ts deliberately
|
||||
// excludes (key present, value undefined) do not.
|
||||
function inCategory(type: string): boolean {
|
||||
if (!typeCategory.has(type)) return true
|
||||
return typeCategory.get(type) === category
|
||||
}
|
||||
|
||||
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
|
||||
// default init spreads those via a spiral centered on the ORIGIN — not
|
||||
// (width/2, height/2) — while the x/y centering forces below are
|
||||
// deliberately weak (0.04, so they don't fight the link/collide layout).
|
||||
// Together that meant the cluster could settle noticeably off-origin
|
||||
// instead of centered. Fixed by explicitly fitting the viewport to the
|
||||
// node bounding box once the simulation settles, rather than relying on
|
||||
// the force balance to land on center by itself.
|
||||
function fitToView() {
|
||||
const placed = nodes.filter((n) => n.x != null && n.y != null)
|
||||
if (!placed.length) return
|
||||
const xs = placed.map((n) => n.x as number)
|
||||
const ys = placed.map((n) => n.y as number)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const pad = 70
|
||||
const bw = Math.max(maxX - minX, 1)
|
||||
const bh = Math.max(maxY - minY, 1)
|
||||
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
|
||||
const cx = (minX + maxX) / 2
|
||||
const cy = (minY + maxY) / 2
|
||||
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
|
||||
}
|
||||
|
||||
// fit=false for passive background reloads (live entity/relationship
|
||||
// events) — those shouldn't yank the view out from under someone
|
||||
// actively panning/zooming. Fresh loads (mount, root/depth change,
|
||||
// reset, re-root) default to fit=true.
|
||||
async function load(fit = true) {
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
@@ -176,8 +136,8 @@
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active category.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
// Edge-type toggles default to everything present — node-type toggles
|
||||
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
@@ -193,17 +153,9 @@
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
.on('end', () => {
|
||||
if (fit) fitToView()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||
// Re-derive the active node types now that category membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
@@ -214,17 +166,11 @@
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the category perspective changes, reset the node-type toggles to it.
|
||||
$effect(() => {
|
||||
category
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load(false)
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -266,14 +212,11 @@
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active category.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allNodeTypes,
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
@@ -288,21 +231,21 @@
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = in the active category AND its node-type toggle is on — these
|
||||
// are what the category tab is "about."
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||
// visible — same control the entity table filters its rows by.
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross category lines (a service sits on
|
||||
// a network, uses storage, runs on an lxc — different categories under
|
||||
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
||||
// left focus nodes looking like disconnected dots. Rooted views (the user
|
||||
// is exploring out from one entity) pull in 1-hop neighbors of any
|
||||
// category, dimmed, so the edges — and what they connect to — stay
|
||||
// visible. Unscoped "browse the whole category" views (no root) skip
|
||||
// this: with ~50 focus nodes that touch nearly everything, 1-hop
|
||||
// expansion floods in most of the graph (measured: 417 of 479 total
|
||||
// entities for an unrooted Fleet view) — worse than the isolated-dot
|
||||
// problem it was meant to fix. There, same-category-only edges stay.
|
||||
// Real infra relationships mostly cross type lines (a service sits on a
|
||||
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||
// other end isn't in the active type set left focus nodes looking like
|
||||
// disconnected dots. Rooted views (the user is exploring out from one
|
||||
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||
// edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
@@ -452,7 +395,7 @@
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#each allRelTypes as type}
|
||||
@@ -461,7 +404,7 @@
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
@@ -472,25 +415,31 @@
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const curve = Math.min(len * 0.15, 40)}
|
||||
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
|
||||
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
|
||||
{@const cdx = t.x - cx}
|
||||
{@const cdy = t.y - cy}
|
||||
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (dx / len) * tr}
|
||||
{@const ey = t.y - (dy / len) * tr}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={ex}
|
||||
y2={ey}
|
||||
{@const ex = t.x - (cdx / clen) * tr}
|
||||
{@const ey = t.y - (cdy / clen) * tr}
|
||||
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
|
||||
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
|
||||
<path
|
||||
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||
fill="none"
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
</path>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={(s.x + ex) / 2}
|
||||
y={(s.y + ey) / 2 - 4}
|
||||
x={mx}
|
||||
y={my - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
|
||||
Reference in New Issue
Block a user