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:
@@ -185,6 +185,7 @@ export interface Entity {
|
||||
updated_at: string
|
||||
health?: EntityHealth | null
|
||||
last_check_at?: string | null
|
||||
maintenance_until?: string | null
|
||||
}
|
||||
|
||||
export interface EntityFilters {
|
||||
@@ -518,14 +519,17 @@ export interface BlastRadiusItem {
|
||||
}
|
||||
|
||||
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
|
||||
const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// id is commonly a slug, not a UUID (e.g. "document:infrastructure/network") —
|
||||
// slugs can contain '/', which must be percent-encoded or it splits the path
|
||||
// into extra segments the router won't match.
|
||||
export async function fetchEntity(id: string): Promise<Entity | null> {
|
||||
const res = await fetchWithAuth(`${API}/entities/${id}`)
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}`)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@@ -621,6 +625,44 @@ export async function fetchEntityExecutions(entityId: string): Promise<Execution
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface EntityTask {
|
||||
task: Entity
|
||||
executionCount: number
|
||||
}
|
||||
|
||||
// Executions aren't directly browsable from an entity in a useful way — what
|
||||
// matters is which task/session acted on it, and how many times. There's no
|
||||
// "incoming relationships" endpoint (blast_radius/graph only walks outgoing
|
||||
// edges), so this composes it client-side: find executions targeting this
|
||||
// entity, then check each task's own outgoing edges (task --involves--> entity
|
||||
// directly, or task --involves--> execution) to attribute them. Cheap while
|
||||
// the task count is small; would want a dedicated query if that changes.
|
||||
export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
||||
const [executions, tasks] = await Promise.all([
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntities({ type: 'task' })
|
||||
])
|
||||
const executionIds = new Set(executions.map((e) => e.id))
|
||||
|
||||
const results = await Promise.all(
|
||||
tasks.map(async (task): Promise<EntityTask | null> => {
|
||||
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
||||
if (!g) return null
|
||||
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
|
||||
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
||||
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
||||
const executionCount = g.edges.filter((e) => {
|
||||
if (e.type !== 'involves') return false
|
||||
const targetId = idBySlug.get(e.target)
|
||||
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
|
||||
}).length
|
||||
if (!involvesThisEntity && executionCount === 0) return null
|
||||
return { task, executionCount }
|
||||
})
|
||||
)
|
||||
return results.filter((r): r is EntityTask => r !== null)
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string
|
||||
slug: string
|
||||
|
||||
44
web/src/lib/categories.ts
Normal file
44
web/src/lib/categories.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Browsing categories for the Knowledge Base — a coarser, more useful axis
|
||||
// than the ontology's own `layer` (infrastructure/governance/cognition),
|
||||
// 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 9 domains into 6 browsing-sized buckets.
|
||||
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
|
||||
|
||||
export const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'identity', label: 'Identity' },
|
||||
{ id: 'knowledge', label: 'Knowledge' }
|
||||
]
|
||||
|
||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||
// domain-registration are network-adjacent); `physical` folds into Fleet
|
||||
// (ups/sensor/site support compute, browsing them separately fragments
|
||||
// "what's running where"). `meta` (the abstract root "entity" type) has no
|
||||
// category — it's never instantiated directly.
|
||||
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||
network: 'network',
|
||||
external: 'network',
|
||||
compute: 'fleet',
|
||||
physical: 'fleet',
|
||||
software: 'services',
|
||||
storage: 'storage',
|
||||
identity: 'identity',
|
||||
cognition: 'knowledge'
|
||||
}
|
||||
|
||||
export function domainToCategory(domain: string): Category | undefined {
|
||||
return DOMAIN_TO_CATEGORY[domain]
|
||||
}
|
||||
|
||||
// A category maps to one domain in most cases, two for Network and Fleet —
|
||||
// used to build the domain-filtered fetches for the table view.
|
||||
export function domainsForCategory(category: Category): string[] {
|
||||
return Object.entries(DOMAIN_TO_CATEGORY)
|
||||
.filter(([, c]) => c === category)
|
||||
.map(([domain]) => domain)
|
||||
}
|
||||
@@ -18,16 +18,16 @@
|
||||
let open = $state(defaultOpen)
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-lg border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-3 py-2 text-left hover:bg-muted/50">
|
||||
<span class="text-sm font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2.5 py-1.5 text-left hover:bg-muted/50">
|
||||
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<div class="border-t px-3 py-2.5">
|
||||
<div class="border-t px-2.5 py-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
fetchMetrics,
|
||||
fetchEntityEvents,
|
||||
fetchEntitySignals,
|
||||
fetchEntityExecutions,
|
||||
fetchEntityTasks,
|
||||
fetchEntityKnowledge,
|
||||
fetchChecksForTarget,
|
||||
fetchAgentActivity,
|
||||
@@ -21,13 +21,13 @@
|
||||
type Relationship,
|
||||
type MetricSeries,
|
||||
type Signal,
|
||||
type Execution,
|
||||
type EntityTask,
|
||||
type KnowledgeHit,
|
||||
type Check,
|
||||
type AgentActivity,
|
||||
type AuditEntry
|
||||
} from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { relativeTime, truncateMiddle } from '$lib/utils'
|
||||
import type { OikosEvent } from '$lib/stores/events'
|
||||
import DetailSection from '$lib/components/DetailSection.svelte'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
@@ -42,7 +42,7 @@
|
||||
let metrics = $state<MetricSeries[]>([])
|
||||
let events = $state<OikosEvent[]>([])
|
||||
let signals = $state<Signal[]>([])
|
||||
let executions = $state<Execution[]>([])
|
||||
let tasks = $state<EntityTask[]>([])
|
||||
let knowledge = $state<KnowledgeHit[]>([])
|
||||
let checks = $state<Check[]>([])
|
||||
let agentActivity = $state<AgentActivity[]>([])
|
||||
@@ -58,12 +58,12 @@
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
|
||||
const [graphView, m, ev, sig, tk, kh, ch, aa, au] = await Promise.all([
|
||||
fetchGraph({ root: entity.id, depth: 1 }),
|
||||
fetchMetrics(entity.id),
|
||||
fetchEntityEvents(entity.id),
|
||||
fetchEntitySignals(entity.id),
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntityTasks(entity),
|
||||
fetchEntityKnowledge(entity.id),
|
||||
fetchChecksForTarget(entity.slug),
|
||||
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
||||
@@ -73,7 +73,7 @@
|
||||
metrics = m
|
||||
events = ev
|
||||
signals = sig
|
||||
executions = exec
|
||||
tasks = tk
|
||||
knowledge = kh
|
||||
checks = ch
|
||||
agentActivity = aa
|
||||
@@ -174,33 +174,113 @@
|
||||
toast.error('Failed to update check')
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes are freeform (no attribute_schema on most entity types), so
|
||||
// the generic key/value list was truncating anything long — including a
|
||||
// document's whole changelog — to an unreadable single line. Recognize a
|
||||
// few common shapes and render them properly instead of hiding them.
|
||||
interface ChangelogEntry {
|
||||
date?: string
|
||||
title?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
|
||||
|
||||
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
|
||||
)
|
||||
}
|
||||
|
||||
function isFlatObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
|
||||
)
|
||||
}
|
||||
|
||||
type AttributeRow =
|
||||
| { key: string; kind: 'long-text'; value: string }
|
||||
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
|
||||
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
|
||||
| { key: string; kind: 'simple'; value: unknown }
|
||||
|
||||
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
|
||||
return Object.entries(attrs).map(([key, value]): AttributeRow => {
|
||||
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
|
||||
return { key, kind: 'long-text', value }
|
||||
}
|
||||
if (isChangelog(value)) return { key, kind: 'changelog', value }
|
||||
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
|
||||
return { key, kind: 'simple', value }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-3 overflow-y-auto p-4 md:p-6">
|
||||
<div class="flex h-full flex-col gap-2 overflow-y-auto p-3 md:p-4">
|
||||
{#if loading}
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<Skeleton class="h-9 w-full" />
|
||||
<Skeleton class="h-9 w-full" />
|
||||
<Skeleton class="h-9 w-full" />
|
||||
<Skeleton class="h-6 w-48" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
<Skeleton class="h-8 w-full" />
|
||||
{:else if !entity}
|
||||
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
||||
<Badge variant="outline">{entity.type}</Badge>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
|
||||
|
||||
<DetailSection title="Monitoring" count={checks.length} defaultOpen={checks.length > 0}>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#snippet detailsContent()}
|
||||
<dl class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Type</dt>
|
||||
<dd><Badge variant="outline">{entity.type}</Badge></dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">State</dt>
|
||||
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground">—</span>{/if}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
||||
<dd>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">not monitored</span>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Version</dt>
|
||||
<dd>{entity.version}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
||||
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
|
||||
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
||||
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
||||
</div>
|
||||
{#if entity.maintenance_until}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
|
||||
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{/snippet}
|
||||
|
||||
{#snippet monitoringContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each checks as check (check.id)}
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
|
||||
<span class="text-muted-foreground">every {check.interval_s}s</span>
|
||||
@@ -218,46 +298,85 @@
|
||||
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Attributes" count={Object.keys(entity.attributes ?? {}).length} defaultOpen={!!entity.attributes && Object.keys(entity.attributes).length > 0}>
|
||||
{#snippet attributesContent()}
|
||||
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||
<dl class="flex flex-col gap-1.5 text-xs">
|
||||
{#each Object.entries(entity.attributes) as [key, value]}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate text-right">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
{@const rows = classifyAttributes(entity.attributes)}
|
||||
<div class="flex flex-col gap-2 text-xs">
|
||||
{#each rows as row (row.key)}
|
||||
{#if row.kind === 'long-text'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
|
||||
</div>
|
||||
{:else if row.kind === 'changelog'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each row.value as entry}
|
||||
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
|
||||
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
||||
</div>
|
||||
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if row.kind === 'flat-object'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
|
||||
{#each Object.entries(row.value) as [subKey, subValue]}
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">
|
||||
{#if row.value !== null && typeof row.value === 'object'}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
|
||||
{:else}
|
||||
{String(row.value)}
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No attributes.</p>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Relations" count={relations.length} defaultOpen={relations.length > 0}>
|
||||
{#snippet relationsContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex items-center gap-1 font-mono text-xs">
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.source)}>{rel.source}</button>
|
||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="hover:underline hover:text-foreground" onclick={() => onSelectEntity(rel.target)}>{rel.target}</button>
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 shrink hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
{:else}
|
||||
<span>{rel.source}</span>
|
||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||
<span>{rel.target}</span>
|
||||
<span class="min-w-0 shrink truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 shrink truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Metrics" count={metrics.length} defaultOpen={metrics.length > 0}>
|
||||
{#snippet metricsContent()}
|
||||
{#if metrics.length}
|
||||
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
{#each metrics as series (series.metric)}
|
||||
@@ -270,12 +389,12 @@
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Signals" count={signals.length} defaultOpen={signals.length > 0}>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#snippet signalsContent()}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each signals as signal (signal.id)}
|
||||
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{signal.kind}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -314,22 +433,35 @@
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Executions" count={executions.length} defaultOpen={executions.length > 0}>
|
||||
{#snippet tasksContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each executions as execution (execution.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{execution.action}</span>
|
||||
<Badge variant="outline">{execution.status}</Badge>
|
||||
{#each tasks as { task, executionCount } (task.id)}
|
||||
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
|
||||
{title}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if outcome}
|
||||
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
||||
{/if}
|
||||
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
<p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Knowledge" count={knowledge.length} defaultOpen={knowledge.length > 0}>
|
||||
{#snippet knowledgeContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each knowledge as hit (hit.id)}
|
||||
<div class="text-xs">
|
||||
@@ -339,10 +471,10 @@
|
||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Recent events" count={events.length} defaultOpen={events.length > 0}>
|
||||
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#snippet eventsContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||
@@ -352,12 +484,12 @@
|
||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Agent activity" count={agentActivity.length} defaultOpen={agentActivity.length > 0}>
|
||||
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#snippet agentActivityContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each agentActivity as activity (activity.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||
@@ -370,12 +502,12 @@
|
||||
<p class="text-xs text-muted-foreground">No agent activity.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
<DetailSection title="Audit trail" count={auditEntries.length} defaultOpen={auditEntries.length > 0}>
|
||||
<div class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{#snippet auditContent()}
|
||||
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{#each auditEntries as entry (entry.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||
@@ -386,6 +518,26 @@
|
||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</DetailSection>
|
||||
{/snippet}
|
||||
|
||||
{@const sections = [
|
||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||
{ key: 'relations', title: 'Relations', count: relations.length, content: relationsContent },
|
||||
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
||||
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
||||
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
|
||||
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
||||
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
||||
|
||||
{#each sections as section (section.key)}
|
||||
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
|
||||
{@render section.content()}
|
||||
</DetailSection>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { domainToCategory, type Category } from '$lib/categories'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
@@ -15,7 +16,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
layer,
|
||||
category,
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
@@ -27,9 +28,9 @@
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
layer: string
|
||||
category: Category
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
@@ -64,9 +65,9 @@
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
// type → ontology layer, so the graph can be scoped client-side (the graph
|
||||
// endpoint itself has no layer param).
|
||||
let typeLayer = $state<Map<string, string>>(new Map())
|
||||
// type → browsing category, so the graph can be scoped client-side (the
|
||||
// graph endpoint itself has no category/domain param).
|
||||
let typeCategory = $state<Map<string, Category>>(new Map())
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
@@ -107,11 +108,11 @@
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
// Node belongs to the active layer? (Unknown types fall back to visible so a
|
||||
// missing ontology entry never blanks the graph.)
|
||||
function inLayer(type: string): boolean {
|
||||
const l = typeLayer.get(type)
|
||||
return l === undefined || l === layer
|
||||
// Node belongs to the active category? (Unknown types fall back to visible
|
||||
// so a missing ontology entry never blanks the graph.)
|
||||
function inCategory(type: string): boolean {
|
||||
const c = typeCategory.get(type)
|
||||
return c === undefined || c === category
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -140,8 +141,8 @@
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Default the node/edge-type toggles to the types present in the active layer.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.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))
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
@@ -161,9 +162,11 @@
|
||||
|
||||
onMount(() => {
|
||||
fetchEntityTypes().then((types) => {
|
||||
typeLayer = new Map(types.map((t) => [t.name, t.layer]))
|
||||
// Re-derive the active node types now that layer membership is known.
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
typeCategory = new Map(
|
||||
types.map((t) => [t.name, domainToCategory(t.domain)]).filter((e): e is [string, Category] => e[1] !== undefined)
|
||||
)
|
||||
// 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()
|
||||
@@ -175,10 +178,10 @@
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
// When the layer perspective changes, reset the node-type toggles to that layer.
|
||||
// When the category perspective changes, reset the node-type toggles to it.
|
||||
$effect(() => {
|
||||
layer
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))
|
||||
category
|
||||
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -227,8 +230,8 @@
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
// Only offer node-type toggles that live in the active layer.
|
||||
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inLayer(n.type)).map((n) => n.type))).sort())
|
||||
// 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.
|
||||
@@ -249,8 +252,8 @@
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Visible = in the active layer AND its node-type toggle is on.
|
||||
const visibleNodeIds = $derived(new Set(nodes.filter((n) => inLayer(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
// Visible = in the active category AND its node-type toggle is on.
|
||||
const visibleNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
@@ -315,14 +318,14 @@
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number } | null>(null)
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y }
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
@@ -342,7 +345,10 @@
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
|
||||
const dx = p.x - panState.startX
|
||||
const dy = p.y - panState.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
||||
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +362,10 @@
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
if (panState && !panState.moved) {
|
||||
// Plain click on empty background (not a drag-pan) — clear selection.
|
||||
onSelect(null)
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
64
web/src/lib/components/MultiSelectFilter.svelte
Normal file
64
web/src/lib/components/MultiSelectFilter.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
options,
|
||||
selected = $bindable(),
|
||||
colorFor
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
selected: Set<string>
|
||||
colorFor?: (option: string) => string
|
||||
} = $props()
|
||||
|
||||
function toggle(opt: string) {
|
||||
const next = new Set(selected)
|
||||
if (next.has(opt)) next.delete(opt)
|
||||
else next.add(opt)
|
||||
selected = next
|
||||
}
|
||||
|
||||
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
|
||||
{label}
|
||||
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
|
||||
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
|
||||
<DropdownMenu.Item
|
||||
closeOnSelect={false}
|
||||
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#each options as opt}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selected.has(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
class="text-xs"
|
||||
>
|
||||
{#if colorFor}
|
||||
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
|
||||
{/if}
|
||||
{opt}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
{#if options.length === 0}
|
||||
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -21,6 +21,16 @@ export function relativeTime(iso: string | null | undefined): string {
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
// Truncates long slugs/names in the middle (keeping the "type:" prefix and
|
||||
// the tail visible) rather than at the end — for slugs the distinguishing
|
||||
// part is often at both ends, e.g. "investigation:nomos/dragonfly-memlock-…"
|
||||
// vs "…rlimit-type-8-in-unprivileged-lxcs".
|
||||
export function truncateMiddle(s: string, maxLen = 36): string {
|
||||
if (s.length <= maxLen) return s;
|
||||
const keep = Math.floor((maxLen - 1) / 2);
|
||||
return `${s.slice(0, keep)}…${s.slice(-keep)}`;
|
||||
}
|
||||
|
||||
// debounce wraps fn so rapid calls (e.g. keystrokes in a filter input)
|
||||
// collapse into one invocation after `wait`ms of silence.
|
||||
export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300): T {
|
||||
|
||||
@@ -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