Files
oikos/web/src/lib/entityView.ts
dtoro ad29295c93
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
feat(web): make the entity window a triage surface, not a data dump
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>
2026-07-29 09:24:28 +02:00

170 lines
6.3 KiB
TypeScript

// 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.`
}