feat(web): make the entity window a triage surface, not a data dump
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The window rendered the same 13 collapsible sections for every entity, sorted
only by "does it have content". Audit trail carried the same visual weight as
Health, and the window answered "what data do we hold about X?" rather than
"what do I need to know, and what should I do?".

Measured against prod: host:hubris has 223 relations, 1,601 events, 2.7M metric
samples and 148 executions; an ingress route has three facts. Both got 13
identical headers. Expanding a host put ~540 interactive elements on screen.

- **A verdict header that never collapses.** Not just "down" but *why*:
  "ping failing · 5 of 6 checks passing". That line did not previously exist
  and could not have — checks rendered as configuration, never as results.
- **Sections composed per type.** A document has no checks, metrics or blast
  radius; a signal or execution is a record, not a thing. Infrastructure gets
  Status/Impact/Activity/Metrics/Reference, knowledge types lead with Content,
  records get a minimal view. Unknown types fall back to infrastructure so a
  new entity type is never a blank window.
- **Status replaces Monitoring**, showing each check's own verdict and when it
  last ran — the section that answers the header's "why".
- **Impact** finally calls /entities/{id}/blast-radius. The endpoint has existed
  since the first API and had no frontend caller anywhere, despite
  .agents/OIKOS.md naming blast radius as the reason the ontology exists. Its
  outgoing-edges-only limitation is stated in the UI rather than hidden.
- **Activity merges four lists** (executions, signals, events, agent activity)
  that were telling one story in four places.
- **Relations cap at 8 with a drill-in** — 540 interactive elements down to 126.
- **Ask Nomos** opens a task pre-scoped to what you are looking at, seeded with
  the verdict just computed, via an optional draft threaded through
  openNewTaskWindow -> NewTaskChat -> ChatThread.

Requires exposing check_defs.last_health/last_run_at through the API (the
columns landed with the health-aggregation work but were never surfaced).
Adding a fourth enum containing "unknown" made oapi-codegen disambiguate all
enum constants by type prefix, so metrics.go moves to gen.TrendDirection*.

Verdict derivation and type->section composition live in $lib/entityView.ts as
pure functions with 15 unit tests, including the host:strong case that
motivated this.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-29 09:24:28 +02:00
parent 6ca6d5b352
commit ad29295c93
12 changed files with 912 additions and 234 deletions

View File

@@ -32,7 +32,8 @@
suggestions = [],
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null
question = null,
initialDraft = ''
}: {
messages: ChatMessage[]
streaming: boolean
@@ -49,9 +50,14 @@
sessionId?: string | null
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
question?: SessionQuestion | null
/** Pre-fills the composer. Used by "Ask Nomos" in the entity window so an
* investigation starts from what the operator was just looking at, rather
* than making them retype it. Left editable on purpose — it is a starting
* point, not a command. */
initialDraft?: string
} = $props()
let input = $state('')
let input = $state(initialDraft)
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)

View File

@@ -16,6 +16,7 @@
fetchEntityKnowledge,
fetchKnowledgeContent,
fetchChecksForTarget,
fetchBlastRadius,
fetchAgentActivity,
fetchAudit,
patchCheck,
@@ -32,11 +33,21 @@
type KnowledgeContent,
type Check,
type AgentActivity,
type AuditEntry
type AuditEntry,
type BlastRadiusItem
} from '$lib/api'
import { relativeTime, truncateMiddle } from '$lib/utils'
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import { isHealthEvent, applyHealthEventTo } from '$lib/health'
import {
deriveVerdict,
sectionsForType,
isObservable,
checkLabel,
nomosPrompt,
type SectionKey
} from '$lib/entityView'
import { openNewTaskWindow } from '$lib/stores/windows'
import DetailSection from '$lib/components/DetailSection.svelte'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
@@ -67,6 +78,8 @@
let checks = $state<Check[]>([])
let agentActivity = $state<AgentActivity[]>([])
let auditEntries = $state<AuditEntry[]>([])
let blast = $state<BlastRadiusItem[]>([])
let showAllRelations = $state(false)
let loading = $state(true)
let actingSignal = $state<string | null>(null)
let chartContainers: Record<string, HTMLDivElement> = {}
@@ -75,6 +88,25 @@
// (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.
// The single most valuable line in the window: not just "down", but which
// probe is responsible. Derived from per-check verdicts (check.last_health),
// which the API only started exposing for this redesign.
// Relations are the one section where a single entity can legitimately have
// hundreds; everything else is naturally bounded.
const RELATION_PREVIEW = 8
const hiddenRelations = $derived(
Math.max(outgoingRelations.length - RELATION_PREVIEW, 0) +
Math.max(incomingRelations.length - RELATION_PREVIEW, 0)
)
const verdict = $derived(deriveVerdict(entity, checks))
const openSignals = $derived(signals.filter((s) => !['resolved', 'failed'].includes(s.state)))
function askNomos() {
if (!entity) return
openNewTaskWindow(nomosPrompt(entity, verdict))
}
const outgoingRelations = $derived(
entity ? relations.filter((r) => r.source === entity!.slug) : []
)
@@ -89,7 +121,7 @@
loading = false
return
}
const [rel, m, ev, sig, ex, tk, kh, oc, ch, aa, au] = await Promise.all([
const [rel, m, ev, sig, ex, tk, kh, oc, ch, aa, au, br] = await Promise.all([
fetchEntityRelations(entity.id),
fetchMetrics(entity.id),
fetchEntityEvents(entity.id),
@@ -100,7 +132,9 @@
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 })
fetchAudit({ entity_id: entity.id, limit: 50 }),
// Only for things that can actually break; a document has no blast radius.
isObservable(entity.type) ? fetchBlastRadius(entity.slug, 2) : Promise.resolve([])
])
relations = rel
metrics = m
@@ -113,6 +147,7 @@
checks = ch
agentActivity = aa
auditEntries = au
blast = br
loading = false
await tick()
@@ -232,7 +267,13 @@
// Health is patched from the event itself — it carries the new value, so
// there is nothing to go and ask for.
if (isHealthEvent(ev)) {
// The dot patches from the event, but the header's *reason* and the
// Status section are derived from per-check verdicts — and a health
// change means some check's verdict just moved. Patching only the entity
// would leave "ping failing" on screen after ping recovered. One small
// fetch, scoped to this entity's checks.
entity = applyHealthEventTo(entity, ev)
refreshChecks()
return
}
@@ -244,6 +285,13 @@
}
}
async function refreshChecks() {
if (!entity) return
const slugAtStart = entity.slug
const next = await fetchChecksForTarget(slugAtStart)
if (entity?.slug === slugAtStart) checks = next
}
async function refreshSignals() {
if (!entity) return
const id = entity.id
@@ -434,7 +482,53 @@
{: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>
<!-- Verdict header. Never collapses, and answers "is this OK, and why?"
before anything else on screen. Replaces a bare slug heading followed
by a Details section that had to be expanded to learn anything. -->
<header class="flex flex-col gap-2 rounded-lg border bg-card px-3 py-2.5">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate font-mono text-sm font-semibold">{entity.slug}</h1>
<div class="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{entity.type}</span>
{#if entity.state && entity.state !== 'active'}
<span>·</span><Badge variant="outline">{entity.state}</Badge>
{/if}
</div>
</div>
<Button size="sm" variant="outline" class="h-7 shrink-0 gap-1.5 text-xs" onclick={askNomos}>
Ask Nomos
</Button>
</div>
{#if isObservable(entity.type)}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 text-sm">
<span
class="size-2.5 shrink-0 rounded-full {verdict.health === 'unmonitored'
? 'bg-muted-foreground/40'
: (healthDot[verdict.health] ?? healthDot.unknown)}"
></span>
<span class="font-medium">{verdict.health}</span>
{#if entity.last_check_at}
<span class="text-xs text-muted-foreground"
>· checked {relativeTime(entity.last_check_at)}</span
>
{/if}
</div>
<!-- The reason. Without this the header would just restate the dot. -->
{#if verdict.reason}
<p class="text-xs text-muted-foreground">{verdict.reason}</p>
{/if}
{#if openSignals.length > 0}
<p class="text-xs text-warning">
{openSignals.length}
open signal{openSignals.length === 1 ? '' : 's'}
</p>
{/if}
</div>
{/if}
</header>
{#snippet detailsContent()}
<dl class="flex flex-col gap-1 text-xs">
@@ -638,7 +732,7 @@
Outgoing ({outgoingRelations.length})
</div>
<div class="flex flex-col gap-1">
{#each outgoingRelations as rel}
{#each showAllRelations ? outgoingRelations : outgoingRelations.slice(0, RELATION_PREVIEW) as rel}
{@render relationRow(rel)}
{/each}
</div>
@@ -652,12 +746,25 @@
Incoming ({incomingRelations.length})
</div>
<div class="flex flex-col gap-1">
{#each incomingRelations as rel}
{#each showAllRelations ? incomingRelations : incomingRelations.slice(0, RELATION_PREVIEW) as rel}
{@render relationRow(rel)}
{/each}
</div>
</div>
{/if}
<!-- host:hubris has 223 relations. Rendering them flat put ~540
interactive elements in one window and buried everything else;
a preview with an explicit drill-in keeps the section scannable
without hiding anything. -->
{#if !showAllRelations && hiddenRelations > 0}
<button
type="button"
class="self-start text-[11px] text-muted-foreground underline-offset-2 hover:underline"
onclick={() => (showAllRelations = true)}
>
Show {hiddenRelations} more
</button>
{/if}
</div>
{/if}
{/snippet}
@@ -883,48 +990,199 @@
</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: 'executions',
title: 'Executions',
count: executions.length,
content: executionsContent
},
{ 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))}
<!-- Status: per-check verdicts. Replaces the old Monitoring section, which
listed configuration ("ssh-script, every 60s, enabled") and so could
never explain why an entity was unhealthy. -->
{#snippet statusContent()}
<div class="flex flex-col gap-1.5">
{#each checks.filter((c) => c.enabled) as check (check.id)}
<div
class="flex items-center justify-between gap-2 border-b pb-1.5 text-xs last:border-0 last:pb-0"
>
<div class="flex min-w-0 items-center gap-2">
<span
class="size-2 shrink-0 rounded-full {check.last_health
? (healthDot[check.last_health] ?? healthDot.unknown)
: 'bg-muted-foreground/30'}"
></span>
<span class="truncate font-mono">{checkLabel(check)}</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-muted-foreground">
<span>
{check.last_run_at ? relativeTime(check.last_run_at) : 'not yet run'}
</span>
<button
type="button"
class="rounded border px-1.5 py-0.5 hover:bg-muted"
onclick={() => toggleCheck(check)}
title="Disable this check">disable</button
>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">
No checks configured. This entity is unmonitored — its health cannot be known.
</p>
{/each}
{#if checks.some((c) => !c.enabled)}
<p class="pt-1 text-[11px] text-muted-foreground">
{checks.filter((c) => !c.enabled).length} disabled
</p>
{/if}
</div>
{/snippet}
{#each sections as section (section.key)}
<DetailSection title={section.title} count={section.count} defaultOpen={section.count > 0}>
{@render section.content()}
</DetailSection>
<!-- Impact: what the ontology exists to answer. -->
{#snippet impactContent()}
<div class="flex flex-col gap-2 text-xs">
{#if blast.length > 1}
<div class="flex flex-col gap-1">
{#each blast.filter((b) => b.depth > 0).slice(0, 12) as b (b.entity.id)}
<button
type="button"
class="flex items-center justify-between gap-2 border-b pb-1 text-left last:border-0 last:pb-0 hover:text-foreground"
onclick={() => onSelectEntity?.(b.entity.slug)}
>
<span class="truncate font-mono text-muted-foreground">{b.entity.slug}</span>
<span class="shrink-0 text-muted-foreground"
>{b.depth} hop{b.depth === 1 ? '' : 's'}</span
>
</button>
{/each}
</div>
<!-- Honest about the limitation rather than overclaiming: the query
walks outgoing edges only, so an entity whose importance comes
from things pointing AT it is under-reported. -->
<p class="text-[11px] text-muted-foreground">
Reachable by following this entity's own dependencies. Does not yet include things that
point at it.
</p>
{:else}
<p class="text-muted-foreground">Nothing downstream depends on this.</p>
{/if}
</div>
{/snippet}
<!-- Activity: one timeline instead of four separate lists telling the same
story (executions, signals, events, agent activity). -->
{#snippet activityContent()}
<div class="flex flex-col gap-3">
{#if openSignals.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Open signals
</h4>
{@render signalsContent()}
</div>
{/if}
{#if executions.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Executions
</h4>
{@render executionsContent()}
</div>
{/if}
{#if tasks.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Tasks
</h4>
{@render tasksContent()}
</div>
{/if}
{#if events.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Events
</h4>
{@render eventsContent()}
</div>
{/if}
{#if openSignals.length + executions.length + tasks.length + events.length === 0}
<p class="text-xs text-muted-foreground">Nothing has happened here yet.</p>
{/if}
</div>
{/snippet}
<!-- Reference: the material you consult, not the material you react to. -->
{#snippet referenceContent()}
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Relations
</h4>
{@render relationsContent()}
</div>
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Attributes
</h4>
{@render attributesContent()}
</div>
{#if knowledge.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Knowledge
</h4>
{@render knowledgeContent()}
</div>
{/if}
{#if agentActivity.length > 0 || auditEntries.length > 0}
<div class="flex flex-col gap-1">
<h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Audit
</h4>
{@render auditContent()}
</div>
{/if}
</div>
{/snippet}
<!-- Sections are composed from the entity's type rather than being a fixed
list of thirteen sorted by "has content". A document is not a host: it
has no checks, metrics or blast radius, and offering it those sections
was most of why this window read as noise. -->
{@const registry: Record<SectionKey, { title: string; count: number; content: any } | null> = {
content: ownContent ? { title: 'Content', count: 1, content: contentContent } : null,
status: {
title: 'Status',
count: checks.filter((c) => c.enabled).length,
content: statusContent
},
impact: {
title: 'Impact',
count: Math.max(blast.length - 1, 0),
content: impactContent
},
activity: {
title: 'Activity',
count: openSignals.length + executions.length + tasks.length,
content: activityContent
},
metrics: metrics.length
? { title: 'Metrics', count: metrics.length, content: metricsContent }
: null,
reference: {
title: 'Reference',
count: outgoingRelations.length + incomingRelations.length,
content: referenceContent
}
}}
{#each sectionsForType(entity.type) as key (key)}
{@const section = registry[key]}
{#if section}
<DetailSection
title={section.title}
count={section.count}
defaultOpen={key === 'status' ||
key === 'content' ||
(key === 'activity' && openSignals.length > 0)}
>
{@render section.content()}
</DetailSection>
{/if}
{/each}
{/if}
</div>

View File

@@ -10,6 +10,10 @@
import { truncateMiddle } from '$lib/utils'
import ChatThread from '$lib/components/ChatThread.svelte'
// Seeded by openNewTaskWindow when a caller (the entity window's "Ask Nomos")
// knows what the task is about.
let { initialDraft = '' }: { initialDraft?: string } = $props()
function onSend(text: string) {
startTask(text, (sessionId) => {
openTaskWindow(sessionId, truncateMiddle(text, 60))
@@ -19,6 +23,7 @@
</script>
<ChatThread
{initialDraft}
messages={[]}
streaming={false}
connectionState="connected"

View File

@@ -15,7 +15,8 @@
wmState,
openEntityWindow,
NEW_TASK_WINDOW_ID,
SESSION_PREFIX
SESSION_PREFIX,
takePendingTaskDraft
} from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte'
@@ -105,7 +106,7 @@
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat />
<NewTaskChat initialDraft={takePendingTaskDraft()} />
{:else if app}
<LazyApp load={app.component} />
{:else}