diff --git a/internal/httpapi/knowledge.go b/internal/httpapi/knowledge.go index ea118ca..8bd2911 100644 --- a/internal/httpapi/knowledge.go +++ b/internal/httpapi/knowledge.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/dtoro/oikos/internal/httpapi/gen" + "github.com/go-chi/chi/v5" "github.com/google/uuid" ) @@ -107,6 +108,43 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request) }) } +// serveKnowledgeContent returns the full markdown body for a document/ +// investigation/runbook entity, by its own entity id or slug. Nothing else +// exposes knowledge_entities.content — GetEntityKnowledge (below) answers a +// different question ("what knowledge references THIS entity"), and +// SearchKnowledge only returns a short ts_headline snippet. The KB detail +// panel needs the entity's own full content when it IS a knowledge entity. +func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + idOrSlug := chi.URLParam(req, "id") + + var title, content, source string + var tags []string + var updatedAt string + err := s.pool.QueryRow(ctx, ` + SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text + FROM knowledge_entities ke + JOIN entities e ON e.id = ke.entity_id + WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug). + Scan(&title, &content, &source, &tags, &updatedAt) + if err != nil { + writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "") + return + } + if tags == nil { + tags = []string{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "title": title, + "content": content, + "source": source, + "tags": tags, + "updated_at": updatedAt, + }) +} + func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) { q := request.Params.Q limit := clampLimit(request.Params.Limit) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index db20873..4301d52 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -157,6 +157,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand // HandlerWithOptions so it wins over any generated catch-all. r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge) + // Custom (non-OpenAPI) route: full markdown content for a knowledge + // entity (document/investigation/runbook) by its own id or slug — the + // generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a + // different question (knowledge referencing this entity), not this one. + r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent) + // Custom (non-OpenAPI) routes: the global activity feed (recency-ordered, // unlike ListExecutions which sorts by target for pagination) and the // per-session "what did this session do" digest. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e0ab700..1a8d5ae 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -601,6 +601,23 @@ export async function fetchEntityKnowledge(entityId: string): Promise { + const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`) + if (!res.ok) return null + return res.json() +} + export async function fetchEntityEvents(entityId: string): Promise { const params = new URLSearchParams({ entity_id: entityId, limit: '50' }) const res = await fetchWithAuth(`${API}/events?${params}`) diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts index 97590d5..e137a87 100644 --- a/web/src/lib/categories.ts +++ b/web/src/lib/categories.ts @@ -4,6 +4,8 @@ // 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. +import type { EntityFilters } from './api' + export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge' export const categories: { id: Category; label: string }[] = [ @@ -18,8 +20,8 @@ export const categories: { id: Category; label: string }[] = [ // 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. +// "what's running where"). `meta` (the abstract root "entity" type) and +// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map. const DOMAIN_TO_CATEGORY: Record = { network: 'network', external: 'network', @@ -27,18 +29,34 @@ const DOMAIN_TO_CATEGORY: Record = { physical: 'fleet', software: 'services', storage: 'storage', - identity: 'identity', - cognition: 'knowledge' + identity: 'identity' } -export function domainToCategory(domain: string): Category | undefined { +// `cognition` is not one thing: document/investigation/runbook are genuine +// long-form knowledge, but the domain also holds execution/check/task/ +// signal/approval/pattern/skill/classification/feedback — operational +// telemetry with its own pages (Operations, Signals, Learning). Mapping the +// whole domain to Knowledge pulled in 245 execution + 25 check entities that +// fan out to a handful of compute nodes via `targets`/`checks` edges, +// flooding the graph. Only the true knowledge types get a category; the +// rest are excluded from Knowledge Base browsing entirely (returns +// undefined, same treatment as the abstract `entity` root type). +const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook']) + +export function typeToCategory(type: string, domain: string): Category | undefined { + if (KNOWLEDGE_TYPES.has(type)) return 'knowledge' + if (domain === 'cognition') return 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[] { +// 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) + .map(([domain]) => ({ domain })) } diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 181e50a..8a27f61 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -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(null) @@ -44,6 +50,7 @@ let signals = $state([]) let tasks = $state([]) let knowledge = $state([]) + let ownContent = $state(null) let checks = $state([]) let agentActivity = $state([]) let auditEntries = $state([]) @@ -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 } | { 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): 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 @@ {/snippet} + {#snippet contentContent()} + {#if ownContent} + +
{@html renderMarkdown(ownContent.content)}
+ {:else} +

No content.

+ {/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} + + diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte index 6ffad38..b3747f9 100644 --- a/web/src/lib/components/EntityGraph.svelte +++ b/web/src/lib/components/EntityGraph.svelte @@ -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() + 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 } { diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte index a64b67b..dfd545c 100644 --- a/web/src/pages/KnowledgeBase.svelte +++ b/web/src/pages/KnowledgeBase.svelte @@ -6,7 +6,7 @@ import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte' import EntityDetailContent from '$lib/components/EntityDetailContent.svelte' import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte' - import { categories, domainsForCategory, type Category } from '$lib/categories' + import { categories, filtersForCategory, type Category } from '$lib/categories' import { Button } from '$lib/components/ui/button' import { Input } from '$lib/components/ui/input' import * as Select from '$lib/components/ui/select' @@ -47,8 +47,8 @@ async function loadTable() { tableLoading = true - const domains = domainsForCategory(category) - const results = await Promise.all(domains.map((domain) => fetchEntities({ domain }))) + const filterSets = filtersForCategory(category) + const results = await Promise.all(filterSets.map((f) => fetchEntities(f))) tableEntities = results.flat() tableLoading = false }