diff --git a/web/src/App.svelte b/web/src/App.svelte index ff00f94..2d491aa 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,10 +1,9 @@ + + + + {title}{count !== undefined ? ` (${count})` : ''} + + +
+ {@render children()} +
+
+
diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 0986253..059276d 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -29,13 +29,13 @@ } from '$lib/api' import { relativeTime } from '$lib/utils' import type { OikosEvent } from '$lib/stores/events' - import * as Card from '$lib/components/ui/card' + import DetailSection from '$lib/components/DetailSection.svelte' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Skeleton } from '$lib/components/ui/skeleton' import { toast } from 'svelte-sonner' - let { slug }: { slug: string } = $props() + let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props() let entity = $state(null) let relations = $state([]) @@ -176,13 +176,12 @@ } -
+
{#if loading} -
- - -
+ + + {:else if !entity}

Entity "{slug}" not found.

{:else} @@ -198,11 +197,8 @@ {/if}
- - - Monitoring ({checks.length}) - - + 0}> +
{#each checks as check (check.id)}
@@ -221,202 +217,175 @@ {:else}

No checks configured for this entity.

{/each} - - +
+ -
- - - Attributes - - - {#if entity.attributes && Object.keys(entity.attributes).length} -
- {#each Object.entries(entity.attributes) as [key, value]} -
-
{key}
-
- {typeof value === 'object' ? JSON.stringify(value) : String(value)} -
-
- {/each} -
- {:else} -

No attributes.

- {/if} -
-
+ 0}> + {#if entity.attributes && Object.keys(entity.attributes).length} +
+ {#each Object.entries(entity.attributes) as [key, value]} +
+
{key}
+
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
+
+ {/each} +
+ {:else} +

No attributes.

+ {/if} +
- - - Relations ({relations.length}) - - - {#each relations as rel} -
+ 0}> +
+ {#each relations as rel} +
+ {#if onSelectEntity} + + —{rel.type}→ + + {:else} {rel.source} —{rel.type}→ {rel.target} -
- {:else} -

No direct relations.

- {/each} - - -
+ {/if} +
+ {:else} +

No direct relations.

+ {/each} +
+ - {#if metrics.length} - - - Metrics - - + 0}> + {#if metrics.length} +
{#each metrics as series (series.metric)}

{series.metric} ({series.rollup})

{/each} - - - {/if} +
+ {:else} +

No metrics tracked.

+ {/if} +
-
- - - Signals - - - {#each signals as signal (signal.id)} -
-
- {signal.kind} -
- {signal.severity} - {signal.state} -
+ 0}> +
+ {#each signals as signal (signal.id)} +
+
+ {signal.kind} +
+ {signal.severity} + {signal.state}
- {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} -
- {#if signal.state === 'raised'} - - {/if} +
+ {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)} +
+ {#if signal.state === 'raised'} ackOpenSignal(signal.id)}>Ack - -
- {/if} -
- {:else} -

None.

- {/each} - - - - - - Executions - - - {#each executions as execution (execution.id)} -
- {execution.action} - {execution.status} -
- {:else} -

None.

- {/each} -
-
- - - - Knowledge - - - {#each knowledge as hit (hit.id)} -
- {hit.type}{hit.title} -
- {:else} -

None linked.

- {/each} -
-
-
- -
- - - Recent events - - - {#each events as ev (ev.id)} -
- {new Date(ev.ts).toLocaleString()} - {ev.type} -
- {:else} -

No events yet.

- {/each} -
-
- - - - Agent activity - - - {#each agentActivity as activity (activity.id)} -
-
- {new Date(activity.ts).toLocaleString()} - {activity.activity_type} + {/if} + +
- {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} -
- {:else} -

No agent activity.

- {/each} -
-
+ {/if} +
+ {:else} +

None.

+ {/each} +
+
- - - Audit trail - - - {#each auditEntries as entry (entry.id)} -
-
- {new Date(entry.ts).toLocaleString()} - {entry.actor_type} -
- {entry.actor_id ?? '—'} · {entry.action} + 0}> +
+ {#each executions as execution (execution.id)} +
+ {execution.action} + {execution.status} +
+ {:else} +

None.

+ {/each} +
+
+ + 0}> +
+ {#each knowledge as hit (hit.id)} +
+ {hit.type}{hit.title} +
+ {:else} +

None linked.

+ {/each} +
+
+ + 0}> +
+ {#each events as ev (ev.id)} +
+ {new Date(ev.ts).toLocaleString()} + {ev.type} +
+ {:else} +

No events yet.

+ {/each} +
+
+ + 0}> +
+ {#each agentActivity as activity (activity.id)} +
+
+ {new Date(activity.ts).toLocaleString()} + {activity.activity_type}
- {:else} -

No audit entries.

- {/each} - - -
+ {activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''} +
+ {:else} +

No agent activity.

+ {/each} +
+ + + 0}> +
+ {#each auditEntries as entry (entry.id)} +
+
+ {new Date(entry.ts).toLocaleString()} + {entry.actor_type} +
+ {entry.actor_id ?? '—'} · {entry.action} +
+ {:else} +

No audit entries.

+ {/each} +
+
{/if}
diff --git a/web/src/lib/components/EntityGraph.svelte b/web/src/lib/components/EntityGraph.svelte new file mode 100644 index 0000000..5013952 --- /dev/null +++ b/web/src/lib/components/EntityGraph.svelte @@ -0,0 +1,476 @@ + + +{#if loading && !nodes.length} + +{:else} +
+ + + {#each allRelTypes as type} + + + + {/each} + + + + {#each links as link} + {@const s = endpoint(link.source)} + {@const t = endpoint(link.target)} + {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)} + {@const vs = linkVisualState(link)} + {@const dx = t.x - s.x} + {@const dy = t.y - s.y} + {@const len = Math.max(Math.hypot(dx, dy), 1)} + {@const tr = nodeRadius(t) + 3} + {@const ex = t.x - (dx / len) * tr} + {@const ey = t.y - (dy / len) * tr} + + {link.type} + + {#if vs.emphasized && view.k >= 0.7} + + {link.type} + + {/if} + {/if} + {/each} + + + {#each nodes as node (node.id)} + {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)} + {@const r = nodeRadius(node)} + {@const op = nodeOpacity(node)} + {@const isFocus = hoveredId === node.id || selectedId === node.id} + {@const isMatch = matchedIds !== null && matchedIds.has(node.id)} + onNodePointerDown(e, node)} + onpointerenter={() => (hoveredId = node.id)} + onpointerleave={() => (hoveredId = null)} + onkeydown={(e) => e.key === 'Enter' && selectNode(node)} + ondblclick={() => rerootTo(node)} + > + {#if isFocus || isMatch} + + {/if} + + {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null} + + {node.slug} + + {/if} + + {/if} + {/each} + + + +
+ scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root +
+
+{/if} diff --git a/web/src/lib/components/EntitySheet.svelte b/web/src/lib/components/EntitySheet.svelte index f74aaf1..f981160 100644 --- a/web/src/lib/components/EntitySheet.svelte +++ b/web/src/lib/components/EntitySheet.svelte @@ -3,16 +3,26 @@ import * as Sheet from '$lib/components/ui/sheet' let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props() + + // Lets a relation click inside the sheet drill into that entity in place, + // without closing/reopening. Resets to the externally-requested slug + // whenever the caller opens the sheet on a different entity. + let currentSlug = $state(slug) + $effect(() => { + currentSlug = slug + }) - {slug ?? 'Entity detail'} + {currentSlug ?? 'Entity detail'} Entity detail panel - {#if slug} - + {#if currentSlug} + {#key currentSlug} + (currentSlug = s)} /> + {/key} {/if} diff --git a/web/src/lib/components/EntityTable.svelte b/web/src/lib/components/EntityTable.svelte new file mode 100644 index 0000000..fbf30cc --- /dev/null +++ b/web/src/lib/components/EntityTable.svelte @@ -0,0 +1,178 @@ + + +{#if loading} +
+ + + + Slug + Type + Name + State + Health + + + + {#each skeletonSlugWidths as slugWidth, i} + + + + + + +
+ + +
+
+
+ {/each} +
+
+
+{:else} + {#snippet sortHead(key: SortKey, label: string)} + + + + {/snippet} +
+ + + + {@render sortHead('slug', 'Slug')} + {@render sortHead('type', 'Type')} + {@render sortHead('name', 'Name')} + {@render sortHead('state', 'State')} + {@render sortHead('health', 'Health')} + + + + {#each sortedEntities as entity (entity.id)} + onSelect(entity.slug)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }} + > + {entity.slug} + {entity.type} + {entity.name} + + {#if entity.state} + {entity.state} + {:else} + + {/if} + + + {#if entity.health} + + + {relativeTime(entity.last_check_at)} + + {:else} + + {/if} + + + {:else} + + No entities in this layer match the filter. + + {/each} + + +
+{/if} diff --git a/web/src/pages/Entities.svelte b/web/src/pages/Entities.svelte deleted file mode 100644 index 533d8df..0000000 --- a/web/src/pages/Entities.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -
-
-

Entities

- {filtered.length} of {entities.length} -
- -
- - - - {typeFilter === 'all' ? 'All types' : typeFilter} - - - All types - {#each types as type} - {type} - {/each} - - -
- - {#if loading} -
- {#each Array(8) as _} - - {/each} -
- {:else} -
- - - - Slug - Type - Name - State - Health - - - - {#each filtered as entity (entity.id)} - openEntity(entity.slug)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEntity(entity.slug) } }} - > - {entity.slug} - {entity.type} - {entity.name} - - {#if entity.state} - {entity.state} - {:else} - - {/if} - - - {#if entity.health} - - - {relativeTime(entity.last_check_at)} - - {:else} - - {/if} - - - {:else} - - No entities match this filter. - - {/each} - - -
- {/if} -
- - diff --git a/web/src/pages/Graph.svelte b/web/src/pages/Graph.svelte deleted file mode 100644 index 59dd9c8..0000000 --- a/web/src/pages/Graph.svelte +++ /dev/null @@ -1,517 +0,0 @@ - - -
-
-

Graph

- - - - -
- - {nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}% - -
- -
- {#if allNodeTypes.length} -
- Nodes - {#each allNodeTypes as type} - - {/each} -
- {/if} - {#if allRelTypes.length} -
- Edges - {#each allRelTypes as type} - - {/each} -
- {/if} -
- - {#if loading && !nodes.length} - - {:else} -
- - - {#each allRelTypes as type} - - - - {/each} - - - - {#each links as link} - {@const s = endpoint(link.source)} - {@const t = endpoint(link.target)} - {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)} - {@const vs = linkVisualState(link)} - {@const dx = t.x - s.x} - {@const dy = t.y - s.y} - {@const len = Math.max(Math.hypot(dx, dy), 1)} - {@const tr = nodeRadius(t) + 3} - {@const ex = t.x - (dx / len) * tr} - {@const ey = t.y - (dy / len) * tr} - - {link.type} - - {#if vs.emphasized && view.k >= 0.7} - - {link.type} - - {/if} - {/if} - {/each} - - - {#each nodes as node (node.id)} - {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)} - {@const r = nodeRadius(node)} - {@const op = nodeOpacity(node)} - {@const isFocus = hoveredId === node.id || selected?.id === node.id} - {@const isMatch = matchedIds !== null && matchedIds.has(node.id)} - onNodePointerDown(e, node)} - onpointerenter={() => (hoveredId = node.id)} - onpointerleave={() => (hoveredId = null)} - onkeydown={(e) => e.key === 'Enter' && selectNode(node)} - ondblclick={() => rerootTo(node)} - > - {#if isFocus || isMatch} - - {/if} - - {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null} - - {node.slug} - - {/if} - - {/if} - {/each} - - - -
- scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root -
-
- {/if} -
- - { if (!open) selected = null }}> - - {#if selected} - - {selected.slug} - {selected.type}{selected.state ? ` — ${selected.state}` : ''} - -
-
- - -
-
-

Attributes

-
{JSON.stringify(selected.attributes, null, 2)}
-
-
-

Relationships

-
- {#each links.filter((l) => endpointId(l.source) === selected!.id || endpointId(l.target) === selected!.id) as link} - {@const sId = endpointId(link.source)} - {@const other = endpoint(sId === selected!.id ? link.target : link.source)} -
- - {sId === selected!.id ? '→' : '←'} {link.type} - - {other?.slug ?? '—'} -
- {:else} -

No relationships in this view.

- {/each} -
-
-
-

Blast radius ({blastRadius.length})

-
- {#each blastRadius as item (item.entity.id)} -
- {item.entity.slug} - depth {item.depth} -
- {:else} -

No downstream dependents.

- {/each} -
-
-
- {/if} -
-
- - diff --git a/web/src/pages/KnowledgeBase.svelte b/web/src/pages/KnowledgeBase.svelte new file mode 100644 index 0000000..25be65a --- /dev/null +++ b/web/src/pages/KnowledgeBase.svelte @@ -0,0 +1,288 @@ + + +
+ +
+ (layer = v as Layer)}> + + {#each layers as l} + {l.label} + {/each} + + + +
+ + +
+
+ + {#if view === 'table'} +
+ + + + {typeFilter === 'all' ? 'All types' : typeFilter} + + + All types + {#each tableTypes as type} + {type} + {/each} + + + {filteredEntities.length} of {tableEntities.length} +
+ {:else} +
+ + + + + + {graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}% + +
+ + {#if graphInfo.allNodeTypes.length || graphInfo.allRelTypes.length} +
+ {#if graphInfo.allNodeTypes.length} +
+ Nodes + {#each graphInfo.allNodeTypes as type} + + {/each} +
+ {/if} + {#if graphInfo.allRelTypes.length} +
+ Edges + {#each graphInfo.allRelTypes as type} + {@const color = graphInfo.relColors.get(type) ?? '#30363d'} + + {/each} +
+ {/if} +
+ {/if} + {/if} + + +
+
+ {#if view === 'graph'} + + {:else} + + {/if} +
+ + + +
+ {#if selectedSlug} + {#key selectedSlug} + + {/key} + {:else} +
+ Select an entity to see its detail. +
+ {/if} +
+
+