Every place that showed entity detail (Knowledge Base's right sidebar, the EntitySheet drawer used by Knowledge and the chat session graph, the standalone /entity/:slug page) now opens the entity in its own floating, draggable, resizable window instead — several can be open side by side, and clicking a relation inside one opens another, building up a stack. Windows are managed by one global wmkit instance (new $lib/stores/windows.ts + $lib/components/EntityDesktop.svelte, mounted once in App.svelte), themed with the app's own card/border/ring tokens rather than wmkit's bundled themes (app.css). - Delete EntitySheet.svelte (redundant) and the KnowledgeBase resizable detail pane; row/graph-node click handlers now call openEntityWindow(slug) instead of setting local sidebar state. - SessionGraph (chat's "Scope" mini-graph): clicking a node opens its window directly instead of a click-through mini-detail panel with its own resize handle and "Full detail" button — that whole subsystem is now dead and removed. Node highlight ring is kept (still useful to see what you last opened) and now clears itself via an effect watching the shared window-manager store, so closing a window drops the highlight instead of leaving it pointing at nothing — same fix applied to Knowledge Base's row highlight. - Compact the entity-detail panel's padding (container + each DetailSection) now that it's typically viewed in a small window rather than a full-height sidebar. - Fix KnowledgeBase's browse pane losing its flex-1/min-w-0 (and thus full width) when the wrapping single-child div around it was removed along with the old detail-pane split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
663 lines
26 KiB
Svelte
663 lines
26 KiB
Svelte
<script lang="ts">
|
|
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,
|
|
fetchEntityRelations,
|
|
fetchMetrics,
|
|
fetchEntityEvents,
|
|
fetchEntitySignals,
|
|
fetchEntityTasks,
|
|
fetchEntityKnowledge,
|
|
fetchKnowledgeContent,
|
|
fetchChecksForTarget,
|
|
fetchAgentActivity,
|
|
fetchAudit,
|
|
patchCheck,
|
|
ackSignal,
|
|
resolveSignal,
|
|
muteSignal,
|
|
type Entity,
|
|
type Relationship,
|
|
type MetricSeries,
|
|
type Signal,
|
|
type EntityTask,
|
|
type KnowledgeHit,
|
|
type KnowledgeContent,
|
|
type Check,
|
|
type AgentActivity,
|
|
type AuditEntry
|
|
} from '$lib/api'
|
|
import { relativeTime, truncateMiddle } from '$lib/utils'
|
|
import type { OikosEvent } from '$lib/stores/events'
|
|
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'
|
|
|
|
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
|
|
|
|
let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()
|
|
|
|
let entity = $state<Entity | null>(null)
|
|
let relations = $state<Relationship[]>([])
|
|
let metrics = $state<MetricSeries[]>([])
|
|
let events = $state<OikosEvent[]>([])
|
|
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[]>([])
|
|
let loading = $state(true)
|
|
let actingSignal = $state<string | null>(null)
|
|
let chartContainers: Record<string, HTMLDivElement> = {}
|
|
|
|
// fetchEntityRelations already scopes to edges incident to this entity
|
|
// (source OR target = entity, both directions), so a plain split by which
|
|
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
|
// sneaking into either group.
|
|
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
|
|
const incomingRelations = $derived(
|
|
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
|
)
|
|
|
|
async function load(s: string) {
|
|
loading = true
|
|
entity = await fetchEntity(s)
|
|
if (!entity) {
|
|
loading = false
|
|
return
|
|
}
|
|
const [rel, m, ev, sig, tk, kh, oc, ch, aa, au] = await Promise.all([
|
|
fetchEntityRelations(entity.id),
|
|
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 })
|
|
])
|
|
relations = rel
|
|
metrics = m
|
|
events = ev
|
|
signals = sig
|
|
tasks = tk
|
|
knowledge = kh
|
|
ownContent = oc
|
|
checks = ch
|
|
agentActivity = aa
|
|
auditEntries = au
|
|
loading = false
|
|
|
|
await tick()
|
|
renderCharts()
|
|
}
|
|
|
|
async function ackOpenSignal(id: string) {
|
|
actingSignal = id
|
|
const result = await ackSignal(id)
|
|
actingSignal = null
|
|
if (result) {
|
|
toast.success('Signal acknowledged')
|
|
signals = signals.map((s) => (s.id === id ? result : s))
|
|
} else {
|
|
toast.error('Acknowledge failed')
|
|
}
|
|
}
|
|
|
|
async function resolveOpenSignal(id: string) {
|
|
actingSignal = id
|
|
const result = await resolveSignal(id)
|
|
actingSignal = null
|
|
if (result) {
|
|
toast.success('Signal resolved')
|
|
signals = signals.map((s) => (s.id === id ? result : s))
|
|
} else {
|
|
toast.error('Resolve failed')
|
|
}
|
|
}
|
|
|
|
async function muteOpenSignal(id: string) {
|
|
actingSignal = id
|
|
const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
|
const result = await muteSignal(id, muteUntil)
|
|
actingSignal = null
|
|
if (result) {
|
|
toast.success('Signal muted for 1h')
|
|
signals = signals.map((s) => (s.id === id ? result : s))
|
|
} else {
|
|
toast.error('Mute failed')
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
load(slug)
|
|
})
|
|
|
|
$effect(() => {
|
|
if (slug) load(slug)
|
|
})
|
|
|
|
function renderCharts() {
|
|
for (const series of metrics) {
|
|
const el = chartContainers[series.metric]
|
|
if (!el) continue
|
|
el.innerHTML = ''
|
|
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
|
|
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
|
|
new uPlot(
|
|
{
|
|
width: el.clientWidth || 400,
|
|
height: 160,
|
|
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
|
|
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
|
scales: { x: { time: true } },
|
|
legend: { show: false }
|
|
},
|
|
[xs, ys],
|
|
el
|
|
)
|
|
}
|
|
}
|
|
|
|
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
|
if (sev === 'critical') return 'destructive'
|
|
if (sev === 'warning') return 'secondary'
|
|
return 'default'
|
|
}
|
|
|
|
const healthDot: Record<string, string> = {
|
|
healthy: 'bg-success',
|
|
degraded: 'bg-warning',
|
|
down: 'bg-destructive',
|
|
stale: 'bg-warning/50',
|
|
unknown: 'bg-muted-foreground/40'
|
|
}
|
|
|
|
async function toggleCheck(check: Check) {
|
|
const updated = await patchCheck(check.id, check.version, { enabled: !check.enabled })
|
|
if (updated) {
|
|
checks = checks.map((c) => (c.id === check.id ? updated : c))
|
|
toast.success(`${check.slug} ${updated.enabled ? 'enabled' : 'disabled'}`)
|
|
} else {
|
|
toast.error('Failed to update check')
|
|
}
|
|
}
|
|
|
|
// Attributes are freeform (no attribute_schema on most entity types), so
|
|
// the generic key/value list was truncating anything long — including a
|
|
// document's whole changelog — to an unreadable single line. Recognize a
|
|
// few common shapes and render them properly instead of hiding them.
|
|
interface ChangelogEntry {
|
|
date?: string
|
|
title?: string
|
|
body?: string
|
|
}
|
|
|
|
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
|
|
|
|
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length > 0 &&
|
|
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
|
|
)
|
|
}
|
|
|
|
function isFlatObject(value: unknown): value is Record<string, unknown> {
|
|
return (
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
Object.values(value as object).every((v) => v === null || typeof v !== 'object')
|
|
)
|
|
}
|
|
|
|
type AttributeRow =
|
|
| { key: string; kind: 'long-text'; value: string }
|
|
| { key: string; kind: 'changelog'; value: ChangelogEntry[] }
|
|
| { 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)) {
|
|
return { key, kind: 'long-text', value }
|
|
}
|
|
if (isChangelog(value)) return { key, kind: 'changelog', value }
|
|
if (isFlatObject(value)) return { key, kind: 'flat-object', value }
|
|
return { key, kind: 'simple', value }
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col gap-1.5 overflow-y-auto p-2">
|
|
{#if loading}
|
|
<Skeleton class="h-6 w-48" />
|
|
<Skeleton class="h-8 w-full" />
|
|
<Skeleton class="h-8 w-full" />
|
|
<Skeleton class="h-8 w-full" />
|
|
{:else if !entity}
|
|
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
|
{:else}
|
|
<h1 class="font-mono text-sm font-semibold">{entity.slug}</h1>
|
|
|
|
{#snippet detailsContent()}
|
|
<dl class="flex flex-col gap-1 text-xs">
|
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
|
<dt class="shrink-0 text-muted-foreground">Type</dt>
|
|
<dd><Badge variant="outline">{entity.type}</Badge></dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
|
<dt class="shrink-0 text-muted-foreground">State</dt>
|
|
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground">—</span>{/if}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
|
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
|
<dd>
|
|
{#if entity.health}
|
|
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
|
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
|
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
|
</span>
|
|
{:else}
|
|
<span class="text-muted-foreground">not monitored</span>
|
|
{/if}
|
|
</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
|
<dt class="shrink-0 text-muted-foreground">Version</dt>
|
|
<dd>{entity.version}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
|
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
|
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
|
</div>
|
|
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
|
|
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
|
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
|
</div>
|
|
{#if entity.maintenance_until}
|
|
<div class="flex items-center justify-between gap-3">
|
|
<dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
|
|
<dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
|
|
</div>
|
|
{/if}
|
|
</dl>
|
|
{/snippet}
|
|
|
|
{#snippet monitoringContent()}
|
|
<div class="flex flex-col gap-1">
|
|
{#each checks as check (check.id)}
|
|
<div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
|
|
<div class="flex items-center gap-2">
|
|
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
|
|
<span class="text-muted-foreground">every {check.interval_s}s</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer"
|
|
onclick={() => toggleCheck(check)}
|
|
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
|
>
|
|
<Badge variant={check.enabled ? 'default' : 'secondary'}>{check.enabled ? 'enabled' : 'disabled'}</Badge>
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
|
|
{/each}
|
|
</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)}
|
|
<div class="flex flex-col gap-2 text-xs">
|
|
{#each rows as row (row.key)}
|
|
{#if row.kind === 'long-text'}
|
|
<div class="flex flex-col gap-0.5">
|
|
<p class="font-mono text-muted-foreground">{row.key}</p>
|
|
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
|
|
</div>
|
|
{:else if row.kind === 'changelog'}
|
|
<div class="flex flex-col gap-0.5">
|
|
<p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
|
|
<div class="flex flex-col gap-1">
|
|
{#each row.value as entry}
|
|
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
|
<div class="flex items-baseline gap-1.5">
|
|
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
|
|
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
|
</div>
|
|
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else if row.kind === 'flat-object'}
|
|
<div class="flex flex-col gap-0.5">
|
|
<p class="font-mono text-muted-foreground">{row.key}</p>
|
|
<dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
|
|
{#each Object.entries(row.value) as [subKey, subValue]}
|
|
<div class="flex items-start justify-between gap-3">
|
|
<dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
|
|
<dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
|
|
</div>
|
|
{/each}
|
|
</dl>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
|
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
|
<dd class="min-w-0 flex-1 break-words text-right">
|
|
{#if row.value !== null && typeof row.value === 'object'}
|
|
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
|
|
{:else}
|
|
{String(row.value)}
|
|
{/if}
|
|
</dd>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No attributes.</p>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet relationRow(rel: Relationship)}
|
|
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
|
{#if onSelectEntity}
|
|
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
|
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
|
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
|
{:else}
|
|
<span class="min-w-0 flex-1 truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
|
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
|
<span class="min-w-0 flex-1 truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet relationsContent()}
|
|
{#if outgoingRelations.length === 0 && incomingRelations.length === 0}
|
|
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
|
{:else}
|
|
<div class="flex flex-col gap-3">
|
|
{#if outgoingRelations.length}
|
|
<div>
|
|
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
|
|
<div class="flex flex-col gap-1">
|
|
{#each outgoingRelations as rel}
|
|
{@render relationRow(rel)}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{#if incomingRelations.length}
|
|
<div>
|
|
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
|
|
<div class="flex flex-col gap-1">
|
|
{#each incomingRelations as rel}
|
|
{@render relationRow(rel)}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet metricsContent()}
|
|
{#if metrics.length}
|
|
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
|
{#each metrics as series (series.metric)}
|
|
<div>
|
|
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
|
<div bind:this={chartContainers[series.metric]}></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No metrics tracked.</p>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet signalsContent()}
|
|
<div class="flex flex-col gap-1.5">
|
|
{#each signals as signal (signal.id)}
|
|
<div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span>{signal.kind}</span>
|
|
<div class="flex items-center gap-1">
|
|
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
|
<Badge variant="outline">{signal.state}</Badge>
|
|
</div>
|
|
</div>
|
|
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
|
|
<div class="flex justify-end gap-1.5">
|
|
{#if signal.state === 'raised'}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-6 px-2 text-xs"
|
|
disabled={actingSignal === signal.id}
|
|
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
|
|
>
|
|
{/if}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-6 px-2 text-xs"
|
|
disabled={actingSignal === signal.id}
|
|
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
|
|
>
|
|
<Button
|
|
size="sm"
|
|
class="h-6 px-2 text-xs"
|
|
disabled={actingSignal === signal.id}
|
|
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">None.</p>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet tasksContent()}
|
|
<div class="flex flex-col gap-1">
|
|
{#each tasks as { task, executionCount } (task.id)}
|
|
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
|
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
|
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
|
{#if onSelectEntity}
|
|
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
|
|
{title}
|
|
</button>
|
|
{:else}
|
|
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
|
|
{/if}
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
{#if outcome}
|
|
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
|
{/if}
|
|
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet knowledgeContent()}
|
|
<div class="flex flex-col gap-1">
|
|
{#each knowledge as hit (hit.id)}
|
|
<div class="text-xs">
|
|
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">None linked.</p>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet eventsContent()}
|
|
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
|
{#each events as ev (ev.id)}
|
|
<div class="flex items-center justify-between gap-2 text-xs">
|
|
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
|
<span class="truncate">{ev.type}</span>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No events yet.</p>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet agentActivityContent()}
|
|
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
|
{#each agentActivity as activity (activity.id)}
|
|
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
|
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
|
</div>
|
|
<span class="truncate text-muted-foreground"
|
|
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
|
>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No agent activity.</p>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet auditContent()}
|
|
<div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
|
{#each auditEntries as entry (entry.id)}
|
|
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
|
<Badge variant="outline">{entry.actor_type}</Badge>
|
|
</div>
|
|
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
|
</div>
|
|
{:else}
|
|
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
|
{/each}
|
|
</div>
|
|
{/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 },
|
|
{ key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent },
|
|
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
|
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
|
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
|
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
|
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
|
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
|
|
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
|
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
|
|
|
{#each sections as section (section.key)}
|
|
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
|
|
{@render section.content()}
|
|
</DetailSection>
|
|
{/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>
|