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:
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"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) {
|
func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledgeRequestObject) (gen.SearchKnowledgeResponseObject, error) {
|
||||||
q := request.Params.Q
|
q := request.Params.Q
|
||||||
limit := clampLimit(request.Params.Limit)
|
limit := clampLimit(request.Params.Limit)
|
||||||
|
|||||||
@@ -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.
|
// HandlerWithOptions so it wins over any generated catch-all.
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
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,
|
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||||
// unlike ListExecutions which sorts by target for pagination) and the
|
// unlike ListExecutions which sorts by target for pagination) and the
|
||||||
// per-session "what did this session do" digest.
|
// per-session "what did this session do" digest.
|
||||||
|
|||||||
@@ -601,6 +601,23 @@ export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeH
|
|||||||
return data.items ?? []
|
return data.items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeContent {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
source: string
|
||||||
|
tags: string[]
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full markdown body for a document/investigation/runbook entity — distinct
|
||||||
|
// from fetchEntityKnowledge, which returns knowledge that references OTHER
|
||||||
|
// entities, not this entity's own content.
|
||||||
|
export async function fetchKnowledgeContent(id: string): Promise<KnowledgeContent | null> {
|
||||||
|
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(id)}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// 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 9 domains into 6 browsing-sized buckets.
|
// groups the 9 domains into 6 browsing-sized buckets.
|
||||||
|
import type { EntityFilters } from './api'
|
||||||
|
|
||||||
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
|
export type Category = 'network' | 'fleet' | 'services' | 'storage' | 'identity' | 'knowledge'
|
||||||
|
|
||||||
export const categories: { id: Category; label: string }[] = [
|
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,
|
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||||
// domain-registration are network-adjacent); `physical` folds into Fleet
|
// domain-registration are network-adjacent); `physical` folds into Fleet
|
||||||
// (ups/sensor/site support compute, browsing them separately fragments
|
// (ups/sensor/site support compute, browsing them separately fragments
|
||||||
// "what's running where"). `meta` (the abstract root "entity" type) has no
|
// "what's running where"). `meta` (the abstract root "entity" type) and
|
||||||
// category — it's never instantiated directly.
|
// `cognition` (see KNOWLEDGE_TYPES below) are handled outside this map.
|
||||||
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||||
network: 'network',
|
network: 'network',
|
||||||
external: 'network',
|
external: 'network',
|
||||||
@@ -27,18 +29,34 @@ const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
|||||||
physical: 'fleet',
|
physical: 'fleet',
|
||||||
software: 'services',
|
software: 'services',
|
||||||
storage: 'storage',
|
storage: 'storage',
|
||||||
identity: 'identity',
|
identity: 'identity'
|
||||||
cognition: 'knowledge'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
return DOMAIN_TO_CATEGORY[domain]
|
||||||
}
|
}
|
||||||
|
|
||||||
// A category maps to one domain in most cases, two for Network and Fleet —
|
// Filter sets to fetch and merge for a category's table view. Most
|
||||||
// used to build the domain-filtered fetches for the table view.
|
// categories are one or two `domain` values; Knowledge is a handful of
|
||||||
export function domainsForCategory(category: Category): string[] {
|
// 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)
|
return Object.entries(DOMAIN_TO_CATEGORY)
|
||||||
.filter(([, c]) => c === category)
|
.filter(([, c]) => c === category)
|
||||||
.map(([domain]) => domain)
|
.map(([domain]) => ({ domain }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMount, tick } from 'svelte'
|
import { onMount, tick } from 'svelte'
|
||||||
import uPlot from 'uplot'
|
import uPlot from 'uplot'
|
||||||
import 'uplot/dist/uPlot.min.css'
|
import 'uplot/dist/uPlot.min.css'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import {
|
import {
|
||||||
fetchEntity,
|
fetchEntity,
|
||||||
fetchGraph,
|
fetchGraph,
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
fetchEntitySignals,
|
fetchEntitySignals,
|
||||||
fetchEntityTasks,
|
fetchEntityTasks,
|
||||||
fetchEntityKnowledge,
|
fetchEntityKnowledge,
|
||||||
|
fetchKnowledgeContent,
|
||||||
fetchChecksForTarget,
|
fetchChecksForTarget,
|
||||||
fetchAgentActivity,
|
fetchAgentActivity,
|
||||||
fetchAudit,
|
fetchAudit,
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
type Signal,
|
type Signal,
|
||||||
type EntityTask,
|
type EntityTask,
|
||||||
type KnowledgeHit,
|
type KnowledgeHit,
|
||||||
|
type KnowledgeContent,
|
||||||
type Check,
|
type Check,
|
||||||
type AgentActivity,
|
type AgentActivity,
|
||||||
type AuditEntry
|
type AuditEntry
|
||||||
@@ -35,6 +39,8 @@
|
|||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
|
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
|
||||||
|
|
||||||
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
|
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
|
||||||
|
|
||||||
let entity = $state<Entity | null>(null)
|
let entity = $state<Entity | null>(null)
|
||||||
@@ -44,6 +50,7 @@
|
|||||||
let signals = $state<Signal[]>([])
|
let signals = $state<Signal[]>([])
|
||||||
let tasks = $state<EntityTask[]>([])
|
let tasks = $state<EntityTask[]>([])
|
||||||
let knowledge = $state<KnowledgeHit[]>([])
|
let knowledge = $state<KnowledgeHit[]>([])
|
||||||
|
let ownContent = $state<KnowledgeContent | null>(null)
|
||||||
let checks = $state<Check[]>([])
|
let checks = $state<Check[]>([])
|
||||||
let agentActivity = $state<AgentActivity[]>([])
|
let agentActivity = $state<AgentActivity[]>([])
|
||||||
let auditEntries = $state<AuditEntry[]>([])
|
let auditEntries = $state<AuditEntry[]>([])
|
||||||
@@ -58,13 +65,14 @@
|
|||||||
loading = false
|
loading = false
|
||||||
return
|
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 }),
|
fetchGraph({ root: entity.id, depth: 1 }),
|
||||||
fetchMetrics(entity.id),
|
fetchMetrics(entity.id),
|
||||||
fetchEntityEvents(entity.id),
|
fetchEntityEvents(entity.id),
|
||||||
fetchEntitySignals(entity.id),
|
fetchEntitySignals(entity.id),
|
||||||
fetchEntityTasks(entity),
|
fetchEntityTasks(entity),
|
||||||
fetchEntityKnowledge(entity.id),
|
fetchEntityKnowledge(entity.id),
|
||||||
|
KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
|
||||||
fetchChecksForTarget(entity.slug),
|
fetchChecksForTarget(entity.slug),
|
||||||
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
||||||
fetchAudit({ entity_id: entity.id, limit: 50 })
|
fetchAudit({ entity_id: entity.id, limit: 50 })
|
||||||
@@ -75,6 +83,7 @@
|
|||||||
signals = sig
|
signals = sig
|
||||||
tasks = tk
|
tasks = tk
|
||||||
knowledge = kh
|
knowledge = kh
|
||||||
|
ownContent = oc
|
||||||
checks = ch
|
checks = ch
|
||||||
agentActivity = aa
|
agentActivity = aa
|
||||||
auditEntries = au
|
auditEntries = au
|
||||||
@@ -210,6 +219,10 @@
|
|||||||
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
|
| { key: string; kind: 'flat-object'; value: Record<string, unknown> }
|
||||||
| { key: string; kind: 'simple'; value: 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[] {
|
function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
|
||||||
return Object.entries(attrs).map(([key, value]): AttributeRow => {
|
return Object.entries(attrs).map(([key, value]): AttributeRow => {
|
||||||
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
|
if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
|
||||||
@@ -300,6 +313,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/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()}
|
{#snippet attributesContent()}
|
||||||
{#if entity.attributes && Object.keys(entity.attributes).length}
|
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||||
{@const rows = classifyAttributes(entity.attributes)}
|
{@const rows = classifyAttributes(entity.attributes)}
|
||||||
@@ -521,6 +543,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{@const sections = [
|
{@const sections = [
|
||||||
|
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
|
||||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||||
@@ -541,3 +564,67 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
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'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
|
|
||||||
export interface GraphInfo {
|
export interface GraphInfo {
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchEntityTypes().then((types) => {
|
fetchEntityTypes().then((types) => {
|
||||||
typeCategory = new Map(
|
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.
|
// 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))
|
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))
|
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.
|
// Focus = in the active category AND its node-type toggle is on — these
|
||||||
const visibleNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))
|
// 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)
|
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||||
|
|
||||||
@@ -279,9 +300,10 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
function nodeOpacity(node: Node): number {
|
function nodeOpacity(node: Node): number {
|
||||||
if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
|
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
||||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
|
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
||||||
return 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 } {
|
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
|
||||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.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 { 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 * as Select from '$lib/components/ui/select'
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
|
|
||||||
async function loadTable() {
|
async function loadTable() {
|
||||||
tableLoading = true
|
tableLoading = true
|
||||||
const domains = domainsForCategory(category)
|
const filterSets = filtersForCategory(category)
|
||||||
const results = await Promise.all(domains.map((domain) => fetchEntities({ domain })))
|
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
|
||||||
tableEntities = results.flat()
|
tableEntities = results.flat()
|
||||||
tableLoading = false
|
tableLoading = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user