fix(web): render full document content, keep graph connected under categories
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Two fixes to the new category taxonomy:

- Knowledge Base couldn't show a document/investigation/runbook's own
  markdown body — knowledge_entities.content was never exposed by any
  endpoint (GetEntityKnowledge answers "what knowledge references this
  entity", not "what is this entity's content"). Add GET
  /api/v1/knowledge/content/{id} and render it with the existing
  marked+DOMPurify pipeline in a new Content section.

- The graph hid any edge whose other endpoint wasn't in the active
  category, so nodes with only cross-category neighbors rendered as
  disconnected dots. Queried the real relationship table: ~70% of infra
  edges cross Fleet/Network/Services/Storage lines (compute+network+
  software+storage+physical used to be one "infrastructure" layer).
  EntityGraph now keeps 1-hop neighbors visible but dimmed instead of
  hiding them, so the edges — and what they connect to — stay visible.

- categories.ts: `cognition` domain conflated true knowledge (document/
  investigation/runbook, 58 entities) with operational telemetry
  (execution/check/task/signal/approval/pattern/skill/classification/
  feedback, 300+ entities with their own Operations/Signals/Learning
  pages). Mapping the whole domain to Knowledge pulled in 245 execution
  entities fanning out from ~17 compute nodes via `targets` edges — the
  single biggest source of graph clutter. Knowledge now maps by type
  (document/investigation/runbook only); the rest of cognition is
  excluded from Knowledge Base browsing entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 10:17:13 +02:00
parent 35c54ceef5
commit 61ad785fef
7 changed files with 208 additions and 20 deletions

View File

@@ -2,6 +2,8 @@
import { onMount, tick } from 'svelte'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import {
fetchEntity,
fetchGraph,
@@ -10,6 +12,7 @@
fetchEntitySignals,
fetchEntityTasks,
fetchEntityKnowledge,
fetchKnowledgeContent,
fetchChecksForTarget,
fetchAgentActivity,
fetchAudit,
@@ -23,6 +26,7 @@
type Signal,
type EntityTask,
type KnowledgeHit,
type KnowledgeContent,
type Check,
type AgentActivity,
type AuditEntry
@@ -35,6 +39,8 @@
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
let entity = $state<Entity | null>(null)
@@ -44,6 +50,7 @@
let signals = $state<Signal[]>([])
let tasks = $state<EntityTask[]>([])
let knowledge = $state<KnowledgeHit[]>([])
let ownContent = $state<KnowledgeContent | null>(null)
let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
let auditEntries = $state<AuditEntry[]>([])
@@ -58,13 +65,14 @@
loading = false
return
}
const [graphView, m, ev, sig, tk, kh, ch, aa, au] = await Promise.all([
const [graphView, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
fetchGraph({ root: entity.id, depth: 1 }),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
fetchEntitySignals(entity.id),
fetchEntityTasks(entity),
fetchEntityKnowledge(entity.id),
KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
fetchChecksForTarget(entity.slug),
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
fetchAudit({ entity_id: entity.id, limit: 50 })
@@ -75,6 +83,7 @@
signals = sig
tasks = tk
knowledge = kh
ownContent = oc
checks = ch
agentActivity = aa
auditEntries = au
@@ -210,6 +219,10 @@
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
| { key: string; kind: 'simple'; value: unknown }
function renderMarkdown(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
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)) {
@@ -300,6 +313,15 @@
</div>
{/snippet}
{#snippet contentContent()}
{#if ownContent}
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
{:else}
<p class="text-xs text-muted-foreground">No content.</p>
{/if}
{/snippet}
{#snippet attributesContent()}
{#if entity.attributes && Object.keys(entity.attributes).length}
{@const rows = classifyAttributes(entity.attributes)}
@@ -521,6 +543,7 @@
{/snippet}
{@const sections = [
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
{ 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 },
@@ -541,3 +564,67 @@
{/each}
{/if}
</div>
<style>
/* Minimal markdown styling for document/investigation/runbook content —
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
so it can't be shared directly). */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>

View File

@@ -3,7 +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 { typeToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo {
@@ -163,7 +163,7 @@
onMount(() => {
fetchEntityTypes().then((types) => {
typeCategory = new Map(
types.map((t) => [t.name, domainToCategory(t.domain)]).filter((e): e is [string, Category] => e[1] !== undefined)
types.map((t) => [t.name, typeToCategory(t.name, 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))
@@ -252,8 +252,29 @@
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).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)))
// Focus = in the active category AND its node-type toggle is on — these
// are what the category tab is "about."
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
// Real infra relationships mostly cross category lines (a service sits on
// a network, uses storage, runs on an lxc — different categories under
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
// left focus nodes looking like disconnected dots. Instead, pull in their
// 1-hop neighbors (any category) so the edges — and what they connect
// to — stay visible, just visually secondary (see nodeOpacity).
const neighborNodeIds = $derived.by(() => {
const neighbors = new Set<string>()
for (const l of links) {
if (!activeRelTypes.has(l.type)) continue
const s = endpointId(l.source)
const t = endpointId(l.target)
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
}
return neighbors
})
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
@@ -279,9 +300,10 @@
})
function nodeOpacity(node: Node): number {
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
return 1
const base = focusNodeIds.has(node.id) ? 1 : 0.4
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
return base
}
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {