The clickable-button variant of relationRow was missing the truncate class that the read-only span fallback already had — long slugs (task UUIDs, exec IDs) rendered at their full pre-truncated length inside a shrink-only flex item, overflowing the narrow detail sidebar and wrapping to extra lines. Give both sides flex-1 + truncate so they share the row's width evenly and always stay on one line. 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-2 overflow-y-auto p-3 md:p-4">
|
|
{#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>
|