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

@@ -588,8 +588,19 @@ export interface BlastRadiusItem {
depth: number
}
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/blast-radius`)
// What is reachable from this entity by walking the dependency graph — the
// question .agents/OIKOS.md says the ontology exists to answer ("what breaks if
// strong goes down?"). Defined since early on but, until the entity window's
// Impact section, never called from anywhere in the app.
//
// Caveat the UI should surface rather than hide: blast_radius walks OUTGOING
// edges only, so it under-reports for an entity whose importance comes from
// things pointing AT it — lxc:caddy returns 4 entities despite terminating
// every *.hubris.network route.
export async function fetchBlastRadius(id: string, depth = 2): Promise<BlastRadiusItem[]> {
const res = await fetchWithAuth(
`${API}/entities/${encodeURIComponent(id)}/blast-radius?depth=${depth}`
)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -1044,6 +1055,12 @@ export interface Check {
zone?: string | null
enabled: boolean
version: number
// This check's own verdict. An entity's health is the worst of these across
// its enabled checks, so this is what explains *why* an entity is degraded —
// e.g. host:strong reads down because one ping probe fails while five
// ssh-script checks pass. Null until the check has run at least once.
last_health?: EntityHealth | null
last_run_at?: string | null
}
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {

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}

View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from 'vitest'
import { deriveVerdict, sectionsForType, isObservable, checkLabel, nomosPrompt } from './entityView'
import type { Check, Entity } from './api'
function entity(partial: Partial<Entity> = {}): Entity {
return {
id: 'e1',
slug: 'host:strong',
type: 'proxmox-host',
name: 'strong',
attributes: {},
version: 1,
created_at: '',
updated_at: '',
...partial
} as Entity
}
function check(partial: Partial<Check> = {}): Check {
return {
id: 'c' + Math.random(),
slug: 'check:x',
kind: 'ssh-script',
interval_s: 60,
timeout_s: 30,
enabled: true,
version: 1,
...partial
} as Check
}
describe('deriveVerdict', () => {
// The case that motivated the redesign: host:strong reads down because one
// ping probe fails from the scheduler's network vantage point, while five
// ssh checks pass. The window must say that, not just "down".
it('names the failing probe rather than counting', () => {
const v = deriveVerdict(entity({ health: 'down' }), [
check({ kind: 'ping', last_health: 'down' }),
check({ config: { script: 'cpu_check.sh' }, last_health: 'healthy' }),
check({ config: { script: 'memory_check.sh' }, last_health: 'healthy' }),
check({ config: { script: 'load_check.sh' }, last_health: 'healthy' }),
check({ config: { script: 'disk_usage_check.sh' }, last_health: 'healthy' }),
check({ config: { script: 'updates_check.sh' }, last_health: 'healthy' })
])
expect(v.health).toBe('down')
expect(v.reason).toContain('ping')
expect(v.reason).toContain('5 of 6 checks passing')
expect(v.failing).toHaveLength(1)
})
it('says nothing when everything passes', () => {
const v = deriveVerdict(entity({ health: 'healthy' }), [
check({ last_health: 'healthy' }),
check({ last_health: 'healthy' })
])
expect(v.reason).toBe('')
expect(v.passing).toBe(2)
})
// "No checks" is a coverage gap, not good news — calling it healthy would be
// a lie by omission, and it is exactly what the unmonitored signal reports.
it('distinguishes unmonitored from healthy', () => {
const v = deriveVerdict(entity(), [])
expect(v.health).toBe('unmonitored')
expect(v.reason).toContain('no checks')
})
it('ignores disabled checks', () => {
const v = deriveVerdict(entity({ health: 'healthy' }), [
check({ last_health: 'healthy' }),
check({ kind: 'ping', last_health: 'down', enabled: false })
])
expect(v.failing).toHaveLength(0)
expect(v.total).toBe(1)
})
it('reports checks that have not run yet without calling them failures', () => {
const v = deriveVerdict(entity({ health: 'healthy' }), [
check({ last_health: 'healthy' }),
check({ config: { script: 'updates_check.sh' } }) // never run
])
expect(v.failing).toHaveLength(0)
expect(v.reason).toContain('not yet run')
})
it('orders failures worst-first so the reason leads with the worst', () => {
const v = deriveVerdict(entity({ health: 'down' }), [
check({ config: { script: 'disk_usage_check.sh' }, last_health: 'degraded' }),
check({ kind: 'ping', last_health: 'down' })
])
expect(v.failing[0].kind).toBe('ping')
expect(v.reason.startsWith('ping')).toBe(true)
})
it('survives a null entity', () => {
expect(deriveVerdict(null, []).health).toBe('unknown')
})
})
describe('checkLabel', () => {
it('prefers the script name over the generic kind', () => {
expect(checkLabel(check({ config: { script: 'cpu_check.sh' } }))).toBe('cpu_check')
expect(checkLabel(check({ kind: 'ping' }))).toBe('ping')
})
})
describe('sectionsForType', () => {
it('gives infrastructure the triage ordering, status first', () => {
expect(sectionsForType('proxmox-host')[0]).toBe('status')
expect(sectionsForType('service')).toContain('impact')
})
// A document has no checks or metrics; offering those sections is noise.
it('leads knowledge types with their content and offers no monitoring', () => {
const s = sectionsForType('document')
expect(s[0]).toBe('content')
expect(s).not.toContain('status')
expect(s).not.toContain('metrics')
})
it('gives record types a minimal view', () => {
expect(sectionsForType('execution')).not.toContain('status')
expect(sectionsForType('signal')).not.toContain('metrics')
})
// A new entity type must never produce a blank window.
it('falls back to infrastructure for unknown types', () => {
expect(sectionsForType('something-new')).toEqual(sectionsForType('lxc'))
})
})
describe('isObservable', () => {
it('separates things that can be probed from records and documents', () => {
expect(isObservable('lxc')).toBe(true)
expect(isObservable('document')).toBe(false)
expect(isObservable('execution')).toBe(false)
})
})
describe('nomosPrompt', () => {
it('scopes the task to the failure when there is one', () => {
const v = deriveVerdict(entity({ health: 'down' }), [
check({ kind: 'ping', last_health: 'down' }),
check({ last_health: 'healthy' })
])
const p = nomosPrompt(entity({ health: 'down' }), v)
expect(p).toContain('host:strong')
expect(p).toContain('ping')
})
it('asks about coverage when nothing is monitored', () => {
const v = deriveVerdict(entity(), [])
expect(nomosPrompt(entity(), v)).toContain('no checks')
})
})

169
web/src/lib/entityView.ts Normal file
View File

@@ -0,0 +1,169 @@
// What an entity window should say, and which sections it should show.
//
// The window used to render the same 13 collapsible sections for every entity,
// sorted only by "does it have content" — so a host with 223 relations and
// 2.7M metric samples looked exactly like an ingress route with three facts,
// and Audit trail carried the same visual weight as Health. Worse, it could
// never say *why* something was unhealthy: it rendered checks as configuration
// ("ssh-script, every 60s, enabled") rather than as results.
//
// Both problems are decided here, as pure functions, so they can be tested
// without a browser and without a database.
import type { Check, Entity, EntityHealth } from '$lib/api'
// ─── Verdict ──────────────────────────────────────────────────────────────
export interface Verdict {
health: EntityHealth | 'unmonitored'
/** One line explaining the health, or '' when there is nothing to explain. */
reason: string
/** Checks whose own verdict is worse than healthy, worst first. */
failing: Check[]
passing: number
total: number
}
const SEVERITY: Record<string, number> = {
down: 0,
degraded: 1,
stale: 2,
unknown: 3,
healthy: 4
}
function severity(h: string | null | undefined): number {
return SEVERITY[h ?? 'unknown'] ?? 3
}
/** What a check is actually probing, for use in a human-readable reason. */
export function checkLabel(check: Check): string {
const script = (check.config as Record<string, unknown> | undefined)?.script
if (typeof script === 'string' && script) return script.replace(/\.sh$/, '')
return check.kind
}
/**
* Derives the entity's health and a one-line reason from its own checks.
*
* Mirrors the backend's aggregation (WorstHealthForTarget): an entity is as
* healthy as its unhealthiest check. Deriving it here as well means the header
* can name the responsible probe, which the entity's stored health alone can
* never do.
*/
export function deriveVerdict(entity: Entity | null, checks: Check[]): Verdict {
const enabled = checks.filter((c) => c.enabled)
const withVerdict = enabled.filter((c) => c.last_health)
if (!entity) {
return { health: 'unknown', reason: '', failing: [], passing: 0, total: 0 }
}
// No checks at all is a distinct state from "checks that all pass" — it is
// the coverage gap the unmonitored signal reports, and saying "healthy"
// here would be a lie by omission.
if (enabled.length === 0) {
return {
health: 'unmonitored',
reason: 'no checks configured for this entity',
failing: [],
passing: 0,
total: 0
}
}
const failing = withVerdict
.filter((c) => c.last_health && c.last_health !== 'healthy')
.sort((a, b) => severity(a.last_health) - severity(b.last_health))
const passing = withVerdict.length - failing.length
// Prefer the entity's stored health (the backend is authoritative and
// accounts for staleness), falling back to the derived worst.
const health: EntityHealth =
entity.health ?? (failing[0]?.last_health as EntityHealth) ?? 'unknown'
if (failing.length === 0) {
const pending = enabled.length - withVerdict.length
return {
health,
reason:
pending > 0 ? `${passing} of ${enabled.length} checks passing, ${pending} not yet run` : '',
failing,
passing,
total: enabled.length
}
}
// Name the probes, not the count: "ping failing" is actionable in a way that
// "1 check failing" is not.
const names = failing.slice(0, 2).map(checkLabel)
const more = failing.length - names.length
const who = names.join(', ') + (more > 0 ? ` +${more} more` : '')
const verb = failing[0].last_health === 'down' ? 'failing' : failing[0].last_health
return {
health,
reason: `${who} ${verb} · ${passing} of ${enabled.length} checks passing`,
failing,
passing,
total: enabled.length
}
}
// ─── Section composition ──────────────────────────────────────────────────
export type SectionKey = 'content' | 'status' | 'impact' | 'activity' | 'metrics' | 'reference'
// Knowledge entities are documents: their content is the point, and they have
// no checks, metrics or signals to show.
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
// Records of something that happened, not things that can be observed.
// Offering them monitoring sections is meaningless.
const RECORD_TYPES = new Set([
'execution',
'signal',
'check',
'approval',
'classification',
'feedback',
'pattern',
'skill'
])
const INFRASTRUCTURE: SectionKey[] = ['status', 'impact', 'activity', 'metrics', 'reference']
const KNOWLEDGE: SectionKey[] = ['content', 'impact', 'reference']
const RECORD: SectionKey[] = ['activity', 'reference']
/**
* The sections this entity type should show, in order.
*
* Unknown types fall back to the infrastructure list rather than rendering
* nothing, so a newly added entity type is never a blank window.
*/
export function sectionsForType(type: string): SectionKey[] {
if (KNOWLEDGE_TYPES.has(type)) return KNOWLEDGE
if (RECORD_TYPES.has(type)) return RECORD
return INFRASTRUCTURE
}
/** Whether this type is worth showing monitoring affordances for at all. */
export function isObservable(type: string): boolean {
return !KNOWLEDGE_TYPES.has(type) && !RECORD_TYPES.has(type)
}
// ─── Ask Nomos ────────────────────────────────────────────────────────────
/**
* A task prompt scoped to what the operator is currently looking at, so
* investigating is one click from seeing rather than a retyped question.
*/
export function nomosPrompt(entity: Entity, verdict: Verdict): string {
if (verdict.health === 'unmonitored') {
return `${entity.slug} has no checks configured. Work out what monitoring it should have and set it up.`
}
if (verdict.failing.length > 0) {
return `Investigate ${entity.slug} — it reads ${verdict.health}: ${verdict.reason}. Find the cause and report what you find.`
}
return `Give me a status summary of ${entity.slug}: what it is, what depends on it, and anything that looks off.`
}

View File

@@ -148,7 +148,20 @@ export function openEntityWindow(slug: string | null): void {
// over.
export const NEW_TASK_WINDOW_ID = 'new-task'
export function openNewTaskWindow(): void {
// Seed text for the next new-task window. Module state rather than a window
// property because wmkit windows carry only geometry — WindowLayer reads this
// when it mounts NewTaskChat. Cleared on read so a later manually-opened task
// does not inherit a stale prompt.
let pendingTaskDraft = ''
export function takePendingTaskDraft(): string {
const draft = pendingTaskDraft
pendingTaskDraft = ''
return draft
}
export function openNewTaskWindow(draft = ''): void {
pendingTaskDraft = draft
if (wm.get(NEW_TASK_WINDOW_ID)) {
wm.restore(NEW_TASK_WINDOW_ID)
wm.focus(NEW_TASK_WINDOW_ID)