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 ?? []
|
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 type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
|
||||||
|
|
||||||
export interface EntityType {
|
export interface EntityType {
|
||||||
|
|||||||
@@ -3,18 +3,11 @@
|
|||||||
// which lumps very different things (an LXC and a DNS record and a storage
|
// which lumps very different things (an LXC and a DNS record and a storage
|
||||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||||
// `domain` field instead, which already draws these lines; this just
|
// `domain` field instead, which already draws these lines; this just
|
||||||
// groups the domains into browsing-sized buckets.
|
// groups the domains into browsing-sized buckets. The Knowledge Base shows
|
||||||
import type { EntityFilters } from './api'
|
// 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 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,
|
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||||
// domain-registration are network-adjacent); `physical`, `software`, and
|
// domain-registration are network-adjacent); `physical`, `software`, and
|
||||||
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
|
// `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
|
if (domain === 'cognition') return undefined
|
||||||
return DOMAIN_TO_CATEGORY[domain]
|
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">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
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 { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import { typeToCategory, type Category } from '$lib/categories'
|
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
|
|
||||||
export interface GraphInfo {
|
export interface GraphInfo {
|
||||||
allNodeTypes: string[]
|
|
||||||
allRelTypes: string[]
|
allRelTypes: string[]
|
||||||
relColors: Map<string, string>
|
relColors: Map<string, string>
|
||||||
visibleCount: number
|
visibleCount: number
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
category,
|
|
||||||
selectedSlug = null,
|
selectedSlug = null,
|
||||||
onSelect,
|
onSelect,
|
||||||
root = $bindable(''),
|
root = $bindable(''),
|
||||||
@@ -24,11 +21,12 @@
|
|||||||
search,
|
search,
|
||||||
reloadToken,
|
reloadToken,
|
||||||
resetToken,
|
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>()),
|
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
|
selectedSlug?: string | null
|
||||||
onSelect: (slug: string | null) => void
|
onSelect: (slug: string | null) => void
|
||||||
root?: string
|
root?: string
|
||||||
@@ -39,7 +37,7 @@
|
|||||||
// this resizable pane), so they can't call load()/resetView() directly.
|
// this resizable pane), so they can't call load()/resetView() directly.
|
||||||
reloadToken: number
|
reloadToken: number
|
||||||
resetToken: number
|
resetToken: number
|
||||||
activeNodeTypes?: Set<string>
|
activeNodeTypes: Set<string>
|
||||||
activeRelTypes?: Set<string>
|
activeRelTypes?: Set<string>
|
||||||
info?: GraphInfo
|
info?: GraphInfo
|
||||||
} = $props()
|
} = $props()
|
||||||
@@ -59,19 +57,20 @@
|
|||||||
type: string
|
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 graph = $state<GraphView | null>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let nodes = $state<Node[]>([])
|
let nodes = $state<Node[]>([])
|
||||||
let links = $state<Link[]>([])
|
let links = $state<Link[]>([])
|
||||||
let sim: Simulation<Node, Link> | null = null
|
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)
|
let hoveredId = $state<string | null>(null)
|
||||||
|
|
||||||
// viewport transform: translate(x, y) scale(k)
|
// viewport transform: translate(x, y) scale(k)
|
||||||
@@ -101,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markerId(type: string): string {
|
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 {
|
function endpoint(end: string | Node): Node | undefined {
|
||||||
@@ -111,46 +110,7 @@
|
|||||||
return typeof end === 'object' ? end.id : end
|
return typeof end === 'object' ? end.id : end
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node belongs to the active category? Types the ontology never returned
|
async function load() {
|
||||||
// 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) {
|
|
||||||
loading = true
|
loading = true
|
||||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||||
loading = false
|
loading = false
|
||||||
@@ -176,8 +136,8 @@
|
|||||||
type: e.type
|
type: e.type
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Default the node/edge-type toggles to the types present in the active category.
|
// Edge-type toggles default to everything present — node-type toggles
|
||||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||||
activeRelTypes = new Set(links.map((l) => l.type))
|
activeRelTypes = new Set(links.map((l) => l.type))
|
||||||
|
|
||||||
sim?.stop()
|
sim?.stop()
|
||||||
@@ -193,17 +153,9 @@
|
|||||||
.on('tick', () => {
|
.on('tick', () => {
|
||||||
nodes = [...nodes]
|
nodes = [...nodes]
|
||||||
})
|
})
|
||||||
.on('end', () => {
|
|
||||||
if (fit) fitToView()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
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()
|
load()
|
||||||
const unsubscribe = subscribeEvents()
|
const unsubscribe = subscribeEvents()
|
||||||
return () => {
|
return () => {
|
||||||
@@ -214,17 +166,11 @@
|
|||||||
|
|
||||||
onDestroy(() => sim?.stop())
|
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(() => {
|
$effect(() => {
|
||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
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)
|
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())
|
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||||
|
|
||||||
// Publish status/legend info up to the parent toolbar.
|
// Publish status/legend info up to the parent toolbar.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
info = {
|
info = {
|
||||||
allNodeTypes,
|
|
||||||
allRelTypes,
|
allRelTypes,
|
||||||
relColors: relColorByType,
|
relColors: relColorByType,
|
||||||
visibleCount: visibleNodeIds.size,
|
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))
|
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
|
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||||
// are what the category tab is "about."
|
// visible — same control the entity table filters its rows by.
|
||||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
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
|
// Real infra relationships mostly cross type lines (a service sits on a
|
||||||
// a network, uses storage, runs on an lxc — different categories under
|
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||||
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
|
// other end isn't in the active type set left focus nodes looking like
|
||||||
// left focus nodes looking like disconnected dots. Rooted views (the user
|
// disconnected dots. Rooted views (the user is exploring out from one
|
||||||
// is exploring out from one entity) pull in 1-hop neighbors of any
|
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||||
// category, dimmed, so the edges — and what they connect to — stay
|
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||||
// visible. Unscoped "browse the whole category" views (no root) skip
|
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||||
// this: with ~50 focus nodes that touch nearly everything, 1-hop
|
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||||
// expansion floods in most of the graph (measured: 417 of 479 total
|
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||||
// entities for an unrooted Fleet view) — worse than the isolated-dot
|
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||||
// problem it was meant to fix. There, same-category-only edges stay.
|
// edges stay.
|
||||||
const neighborNodeIds = $derived.by(() => {
|
const neighborNodeIds = $derived.by(() => {
|
||||||
const neighbors = new Set<string>()
|
const neighbors = new Set<string>()
|
||||||
if (!root.trim()) return neighbors
|
if (!root.trim()) return neighbors
|
||||||
@@ -452,7 +395,7 @@
|
|||||||
onpointercancel={onPointerUp}
|
onpointercancel={onPointerUp}
|
||||||
>
|
>
|
||||||
<defs>
|
<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" />
|
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||||
</pattern>
|
</pattern>
|
||||||
{#each allRelTypes as type}
|
{#each allRelTypes as type}
|
||||||
@@ -461,7 +404,7 @@
|
|||||||
</marker>
|
</marker>
|
||||||
{/each}
|
{/each}
|
||||||
</defs>
|
</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 transform="translate({view.x},{view.y}) scale({view.k})">
|
||||||
<g>
|
<g>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
@@ -472,25 +415,31 @@
|
|||||||
{@const dx = t.x - s.x}
|
{@const dx = t.x - s.x}
|
||||||
{@const dy = t.y - s.y}
|
{@const dy = t.y - s.y}
|
||||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
{@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 tr = nodeRadius(t) + 3}
|
||||||
{@const ex = t.x - (dx / len) * tr}
|
{@const ex = t.x - (cdx / clen) * tr}
|
||||||
{@const ey = t.y - (dy / len) * tr}
|
{@const ey = t.y - (cdy / clen) * tr}
|
||||||
<line
|
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
|
||||||
x1={s.x}
|
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
|
||||||
y1={s.y}
|
<path
|
||||||
x2={ex}
|
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||||
y2={ey}
|
fill="none"
|
||||||
stroke={relColor(link.type)}
|
stroke={relColor(link.type)}
|
||||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||||
opacity={vs.opacity}
|
opacity={vs.opacity}
|
||||||
marker-end="url(#{markerId(link.type)})"
|
marker-end="url(#{markerId(link.type)})"
|
||||||
>
|
>
|
||||||
<title>{link.type}</title>
|
<title>{link.type}</title>
|
||||||
</line>
|
</path>
|
||||||
{#if vs.emphasized && view.k >= 0.7}
|
{#if vs.emphasized && view.k >= 0.7}
|
||||||
<text
|
<text
|
||||||
x={(s.x + ex) / 2}
|
x={mx}
|
||||||
y={(s.y + ey) / 2 - 4}
|
y={my - 4}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-size={10 / view.k}
|
font-size={10 / view.k}
|
||||||
fill={relColor(link.type)}
|
fill={relColor(link.type)}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
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 { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import EntityTable from '$lib/components/EntityTable.svelte'
|
import EntityTable from '$lib/components/EntityTable.svelte'
|
||||||
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
||||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.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 { openEntityWindow, wmState } from '$lib/stores/windows'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Input } from '$lib/components/ui/input'
|
import { Input } from '$lib/components/ui/input'
|
||||||
import * as Select from '$lib/components/ui/select'
|
|
||||||
import { Switch } from '$lib/components/ui/switch'
|
import { Switch } from '$lib/components/ui/switch'
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||||
@@ -23,8 +22,11 @@
|
|||||||
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
|
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
|
||||||
}
|
}
|
||||||
|
|
||||||
let category = $state<Category>('fleet')
|
|
||||||
let view = $state<View>(loadView())
|
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 —
|
// Tracks only the most recently opened entity, for row/node highlight —
|
||||||
// actual detail viewing now happens in floating windows (EntityDesktop),
|
// actual detail viewing now happens in floating windows (EntityDesktop),
|
||||||
// which can have several entities open at once.
|
// which can have several entities open at once.
|
||||||
@@ -47,34 +49,34 @@
|
|||||||
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
|
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
|
// 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
|
// 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
|
// truncate. Both views now share the same full entity set — there's no
|
||||||
// detail pane both start flush under the toolbar and end up the same height.
|
// more per-category server-side scoping, only the client-side type
|
||||||
let tableEntities = $state<Entity[]>([])
|
// multiselect (activeTypes) below, which both the table (row visibility)
|
||||||
let tableLoading = $state(true)
|
// and the graph (node visibility) read from.
|
||||||
let query = $state('')
|
let allEntities = $state<Entity[]>([])
|
||||||
let typeFilter = $state('all')
|
let entitiesLoading = $state(true)
|
||||||
let showInactive = $state(false)
|
let showInactive = $state(false)
|
||||||
// child entity slug -> parent entity slug, derived from the ontology graph
|
// child entity slug -> parent entity slug, derived from the ontology graph
|
||||||
// (see loadFleetGrouping). Only populated for the fleet category — feeds
|
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
|
||||||
// EntityTable's treegrid grouping, nesting e.g.
|
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
|
||||||
// cluster -> host -> lxc -> service, or storage-pool -> volume -> dataset.
|
// 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 childToParent = $state<Map<string, string> | null>(null)
|
||||||
|
|
||||||
let ontologyCache: Promise<Ontology> | null = null
|
let ontologyPromise: Promise<Ontology> | null = null
|
||||||
|
function getOntology(): Promise<Ontology> {
|
||||||
function isOrDescendsFrom(byName: Map<string, EntityType>, typeName: string, ancestor: string): boolean {
|
ontologyPromise ??= fetchOntology()
|
||||||
if (typeName === ancestor) return true
|
return ontologyPromise
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 —
|
// Distance from the ontology's abstract root ("entity") down to typeName —
|
||||||
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
|
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
|
||||||
// specificity score: a relationship whose parent-side type is a generic
|
// 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
|
// parent (e.g. many hosts are located-at one site). many-to-many
|
||||||
// relationships (mounts, stores-on, backs-up-to, ...) have no single
|
// relationships (mounts, stores-on, backs-up-to, ...) have no single
|
||||||
// parent, so they're excluded from tree nesting. A candidate parent that
|
// 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`,
|
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
|
||||||
// filtered out below) is dropped rather than kept as a dangling pointer —
|
// out below) is dropped rather than kept as a dangling pointer — that's
|
||||||
// that's also what lets `located-at` surface as a host's parent instead of
|
// 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` without any special-cased priority: with cluster absent,
|
||||||
// member-of simply has nothing valid to point at. An entity can still be
|
// member-of simply has nothing valid to point at. An entity can still be
|
||||||
// the child end of several different *remaining* relationship types at
|
// 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
|
// repo) — only one can win as its tree parent, so ties go to the more
|
||||||
// specific relationship (see typeDepth) rather than whichever was fetched
|
// specific relationship (see typeDepth) rather than whichever was fetched
|
||||||
// last.
|
// last.
|
||||||
async function loadFleetGrouping(fleetEntities: Entity[]): Promise<Map<string, string>> {
|
async function loadGrouping(entities: Entity[]): Promise<Map<string, string>> {
|
||||||
ontologyCache ??= fetchOntology()
|
const { entityTypes, relationshipTypes } = await getOntology()
|
||||||
const { entityTypes, relationshipTypes } = await ontologyCache
|
|
||||||
const byName = new Map(entityTypes.map((t) => [t.name, t]))
|
const byName = new Map(entityTypes.map((t) => [t.name, t]))
|
||||||
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
|
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
|
||||||
const relTypeNames = hierRels.map((rt) => rt.name)
|
const relTypeNames = hierRels.map((rt) => rt.name)
|
||||||
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
|
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 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
|
// One whole-graph fetch instead of one rooted fetch per candidate parent
|
||||||
// relationship if it can be that relationship's source.
|
// — now that grouping runs over the entire entity set rather than a
|
||||||
const roots = fleetEntities.filter((e) =>
|
// ~50-entity category, firing a request per entity blew past the
|
||||||
hierRels.some((rt) => isOrDescendsFrom(byName, e.type, rt.source_type))
|
// browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
|
||||||
)
|
const g = await fetchGraph({ relType: relTypeNames })
|
||||||
const pairs = await Promise.all(
|
const pairs = (g?.edges ?? [])
|
||||||
roots.map(async (root) => {
|
.filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
|
||||||
const g = await fetchGraph({ root: root.slug, depth: 1, relType: relTypeNames })
|
.map((edge) => {
|
||||||
return (g?.edges ?? [])
|
const [child, parent] =
|
||||||
.filter((edge) => edge.source === root.slug && cardinalityByType.has(edge.type))
|
cardinalityByType.get(edge.type) === 'many-to-one'
|
||||||
.map((edge) => {
|
? [edge.source, edge.target] // source is the child; target is the "one" (parent)
|
||||||
const [child, parent] =
|
: [edge.target, edge.source] // source is the "one" (parent); target is the child
|
||||||
cardinalityByType.get(edge.type) === 'many-to-one'
|
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
|
||||||
? [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))
|
|
||||||
})
|
})
|
||||||
)
|
|
||||||
const best = new Map<string, { parent: string; weight: number }>()
|
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)
|
const current = best.get(child)
|
||||||
if (!current || weight > current.weight) best.set(child, { parent, weight })
|
if (!current || weight > current.weight) best.set(child, { parent, weight })
|
||||||
}
|
}
|
||||||
return new Map([...best].map(([child, { parent }]) => [child, parent]))
|
return new Map([...best].map(([child, { parent }]) => [child, parent]))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTable() {
|
async function loadEntities() {
|
||||||
tableLoading = true
|
entitiesLoading = true
|
||||||
const filterSets = filtersForCategory(category)
|
// cluster entities are dropped so a host's `member-of` edge has no valid
|
||||||
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
|
// parent to point at, leaving `located-at` (site) as the only remaining
|
||||||
// cluster entities aren't shown in Fleet browsing — with them absent, a
|
// tree-parent candidate (see loadGrouping).
|
||||||
// host's `member-of` edge has no valid parent to point at, so
|
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
|
||||||
// `located-at` (site) is the only remaining candidate and wins the
|
childToParent = await loadGrouping(fetched)
|
||||||
// tree-parent tie-break without a hardcoded relationship priority (see
|
allEntities = fetched
|
||||||
// loadFleetGrouping).
|
entitiesLoading = false
|
||||||
const fetched = results.flat().filter((e) => e.type !== 'cluster')
|
|
||||||
childToParent = category === 'fleet' ? await loadFleetGrouping(fetched) : null
|
|
||||||
tableEntities = fetched
|
|
||||||
tableLoading = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
loadEntities()
|
||||||
|
getOntology().then((o) => {
|
||||||
|
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
|
||||||
|
})
|
||||||
const unsubscribe = subscribeEvents()
|
const unsubscribe = subscribeEvents()
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (view !== 'table') return
|
|
||||||
category
|
|
||||||
loadTable()
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return
|
if (!ev || !ev.type.startsWith('entity.')) return
|
||||||
loadTable()
|
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 filteredEntities = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
return tableEntities.filter((e) => {
|
return allEntities.filter((e) => {
|
||||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
if (!activeTypes.has(e.type)) return false
|
||||||
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) 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
|
// entities with no tracked lifecycle state (state is null) aren't
|
||||||
// "destroyed or inactive" — only hide ones whose tracked state has
|
// "destroyed or inactive" — only hide ones whose tracked state has
|
||||||
@@ -196,12 +199,10 @@
|
|||||||
// width instead of being squeezed by the resizable browse pane.
|
// width instead of being squeezed by the resizable browse pane.
|
||||||
let graphRoot = $state('')
|
let graphRoot = $state('')
|
||||||
let graphDepth = $state(2)
|
let graphDepth = $state(2)
|
||||||
let graphSearch = $state('')
|
|
||||||
let graphReloadToken = $state(0)
|
let graphReloadToken = $state(0)
|
||||||
let graphResetToken = $state(0)
|
let graphResetToken = $state(0)
|
||||||
let graphActiveNodeTypes = $state<Set<string>>(new Set())
|
|
||||||
let graphActiveRelTypes = $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() {
|
function commitGraphQuery() {
|
||||||
graphReloadToken++
|
graphReloadToken++
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
|
|
||||||
function resetGraph() {
|
function resetGraph() {
|
||||||
graphRoot = ''
|
graphRoot = ''
|
||||||
graphSearch = ''
|
search = ''
|
||||||
graphResetToken++
|
graphResetToken++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,97 +220,76 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-3 p-4">
|
<div class="flex h-full flex-col gap-3 p-4">
|
||||||
<!-- toolbar: category perspective + view toggle -->
|
<!-- single toolbar row: search + type filter are shared by both views
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
(one multiselect instead of a category tab, a single-select "All
|
||||||
<div class="inline-flex overflow-hidden rounded-md border">
|
types" dropdown, and a separate graph node-type toggle), the rest is
|
||||||
{#each categories as c}
|
view-specific, and the graph/table switch sits inline with the rest
|
||||||
<Button
|
instead of floating in its own row. -->
|
||||||
variant={category === c.id ? 'secondary' : 'ghost'}
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
size="sm"
|
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
|
||||||
class="h-8 rounded-none border-0"
|
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
|
||||||
onclick={() => (category = c.id)}
|
|
||||||
>
|
{#if view === 'table'}
|
||||||
{c.label}
|
<div class="flex items-center gap-1.5">
|
||||||
</Button>
|
<Switch id="show-inactive" bind:checked={showInactive} />
|
||||||
{/each}
|
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
|
||||||
</div>
|
</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">
|
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
|
||||||
<Button
|
<Button
|
||||||
variant={view === 'graph' ? 'secondary' : 'ghost'}
|
variant={view === 'graph' ? 'secondary' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 rounded-none border-0"
|
class="h-8 rounded-none border-0 px-2"
|
||||||
onclick={() => setView('graph')}
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant={view === 'table' ? 'secondary' : 'ghost'}
|
variant={view === 'table' ? 'secondary' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 rounded-none border-0"
|
class="h-8 rounded-none border-0 px-2"
|
||||||
onclick={() => setView('table')}
|
onclick={() => setView('table')}
|
||||||
|
title="Table view"
|
||||||
|
aria-label="Table view"
|
||||||
>
|
>
|
||||||
<TableIcon class="mr-1 size-3.5" /> Table
|
<TableIcon class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<!-- browse pane — selecting an entity opens it in a floating window
|
||||||
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
|
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
|
||||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
{#if view === 'graph'}
|
{#if view === 'graph'}
|
||||||
<EntityGraph
|
<EntityGraph
|
||||||
{category}
|
|
||||||
selectedSlug={lastOpened}
|
selectedSlug={lastOpened}
|
||||||
onSelect={select}
|
onSelect={select}
|
||||||
bind:root={graphRoot}
|
bind:root={graphRoot}
|
||||||
depth={graphDepth}
|
depth={graphDepth}
|
||||||
search={graphSearch}
|
{search}
|
||||||
reloadToken={graphReloadToken}
|
reloadToken={graphReloadToken}
|
||||||
resetToken={graphResetToken}
|
resetToken={graphResetToken}
|
||||||
bind:activeNodeTypes={graphActiveNodeTypes}
|
activeNodeTypes={activeTypes}
|
||||||
bind:activeRelTypes={graphActiveRelTypes}
|
bind:activeRelTypes={graphActiveRelTypes}
|
||||||
bind:info={graphInfo}
|
bind:info={graphInfo}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
|
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user