fix(web): render full document content, keep graph connected under categories
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user