diff --git a/api/openapi.yaml b/api/openapi.yaml index b841975..5583d6d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2284,6 +2284,23 @@ components: type: boolean version: type: integer + last_health: + type: string + description: >- + This check's own most recent verdict. An entity's health is the + worst of these across its enabled checks, so this is what explains + *why* an entity is degraded. Null until the check first runs. + nullable: true + enum: + - healthy + - degraded + - down + - unknown + last_run_at: + type: string + format: date-time + description: When this check last executed. Null = never run. + nullable: true CheckCreate: type: object required: diff --git a/internal/httpapi/checks.go b/internal/httpapi/checks.go index c11fd50..dd4f5c0 100644 --- a/internal/httpapi/checks.go +++ b/internal/httpapi/checks.go @@ -22,7 +22,7 @@ func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject SELECT cd.entity_id, e.slug, cd.kind, COALESCE(te.slug, '') AS target_slug, cd.target_type, cd.config, cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, - e.version + e.version, cd.last_health, cd.last_run_at FROM check_defs cd JOIN entities e ON e.id = cd.entity_id LEFT JOIN entities te ON te.id = cd.target_id @@ -43,10 +43,19 @@ func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject var c gen.Check var targetSlug string var configBytes []byte + // last_health is what turns a check list from configuration into an + // explanation: an entity's health is the worst of these, so this is + // the field that says which probe is responsible. + var lastHealth *string if err := rows.Scan(&c.Id, &c.Slug, &c.Kind, &targetSlug, &c.TargetType, - &configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version); err != nil { + &configBytes, &c.IntervalS, &c.TimeoutS, &c.Zone, &c.Enabled, &c.Version, + &lastHealth, &c.LastRunAt); err != nil { return nil, err } + if lastHealth != nil { + h := gen.CheckLastHealth(*lastHealth) + c.LastHealth = &h + } if targetSlug != "" { c.Target = &targetSlug } @@ -285,6 +294,15 @@ func checkDefToGen(cd sqlcgen.CheckDef) gen.Check { if len(cd.Config) > 0 && json.Unmarshal(cd.Config, &config) == nil && len(config) > 0 { c.Config = &config } + // Carried through so toggling a check does not blank its verdict in the + // UI — the entity window renders last_health to explain which probe is + // responsible for an entity's health, and a patch response missing it + // would erase that until the next poll. + c.LastRunAt = cd.LastRunAt + if cd.LastHealth != nil { + h := gen.CheckLastHealth(*cd.LastHealth) + c.LastHealth = &h + } return c } diff --git a/internal/httpapi/gen/api.gen.go b/internal/httpapi/gen/api.gen.go index 90f7d95..57e5484 100644 --- a/internal/httpapi/gen/api.gen.go +++ b/internal/httpapi/gen/api.gen.go @@ -86,6 +86,14 @@ const ( CheckKindTcp CheckKind = "tcp" ) +// Defines values for CheckLastHealth. +const ( + CheckLastHealthDegraded CheckLastHealth = "degraded" + CheckLastHealthDown CheckLastHealth = "down" + CheckLastHealthHealthy CheckLastHealth = "healthy" + CheckLastHealthUnknown CheckLastHealth = "unknown" +) + // Defines values for CheckCreateKind. const ( CheckCreateKindCertExpiry CheckCreateKind = "cert-expiry" @@ -273,10 +281,10 @@ const ( // Defines values for TrendDirection. const ( - Degrading TrendDirection = "degrading" - Improving TrendDirection = "improving" - Stable TrendDirection = "stable" - Unknown TrendDirection = "unknown" + TrendDirectionDegrading TrendDirection = "degrading" + TrendDirectionImproving TrendDirection = "improving" + TrendDirectionStable TrendDirection = "stable" + TrendDirectionUnknown TrendDirection = "unknown" ) // Defines values for ListApprovalsParamsStatus. @@ -462,7 +470,13 @@ type Check struct { Id openapi_types.UUID `json:"id"` IntervalS int `json:"interval_s"` Kind CheckKind `json:"kind"` - Slug string `json:"slug"` + + // LastHealth This check's own most recent verdict. An entity's health is the worst of these across its enabled checks, so this is what explains *why* an entity is degraded. Null until the check first runs. + LastHealth *CheckLastHealth `json:"last_health"` + + // LastRunAt When this check last executed. Null = never run. + LastRunAt *time.Time `json:"last_run_at"` + Slug string `json:"slug"` // Target Entity slug (instance-scoped) Target *string `json:"target"` @@ -477,6 +491,9 @@ type Check struct { // CheckKind defines model for Check.Kind. type CheckKind string +// CheckLastHealth This check's own most recent verdict. An entity's health is the worst of these across its enabled checks, so this is what explains *why* an entity is degraded. Null until the check first runs. +type CheckLastHealth string + // CheckCreate defines model for CheckCreate. type CheckCreate struct { Config *map[string]interface{} `json:"config,omitempty"` @@ -8208,175 +8225,177 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "7gAA", + "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", + "LrhQq++5Sg6fP37+/wEAAP//VvsxiT/wAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/httpapi/metrics.go b/internal/httpapi/metrics.go index 51fbd4f..347cf53 100644 --- a/internal/httpapi/metrics.go +++ b/internal/httpapi/metrics.go @@ -155,14 +155,14 @@ func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) f, _ := slopeNum.Float64Value() t.Slope = float32Ptr(float32(f.Float64)) if f.Float64 > 0.01 { - t.Direction = gen.Improving + t.Direction = gen.TrendDirectionImproving } else if f.Float64 < -0.01 { - t.Direction = gen.Degrading + t.Direction = gen.TrendDirectionDegrading } else { - t.Direction = gen.Stable + t.Direction = gen.TrendDirectionStable } } else { - t.Direction = gen.Unknown + t.Direction = gen.TrendDirectionUnknown } items = append(items, t) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2437efc..58df273 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -588,8 +588,19 @@ export interface BlastRadiusItem { depth: number } -export async function fetchBlastRadius(id: string): Promise { - 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 { + 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 { diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index 76da91c..3275945 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -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(null) let scrolledUp = $state(false) let container = $state(null) diff --git a/web/src/lib/components/EntityDetailContent.svelte b/web/src/lib/components/EntityDetailContent.svelte index 8cf90ef..ffefa6c 100644 --- a/web/src/lib/components/EntityDetailContent.svelte +++ b/web/src/lib/components/EntityDetailContent.svelte @@ -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([]) let agentActivity = $state([]) let auditEntries = $state([]) + let blast = $state([]) + let showAllRelations = $state(false) let loading = $state(true) let actingSignal = $state(null) let chartContainers: Record = {} @@ -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}

Entity "{slug}" not found.

{:else} -

{entity.slug}

+ +
+
+
+

{entity.slug}

+
+ {entity.type} + {#if entity.state && entity.state !== 'active'} + ·{entity.state} + {/if} +
+
+ +
+ + {#if isObservable(entity.type)} +
+
+ + {verdict.health} + {#if entity.last_check_at} + · checked {relativeTime(entity.last_check_at)} + {/if} +
+ + {#if verdict.reason} +

{verdict.reason}

+ {/if} + {#if openSignals.length > 0} +

+ {openSignals.length} + open signal{openSignals.length === 1 ? '' : 's'} +

+ {/if} +
+ {/if} +
{#snippet detailsContent()}
@@ -638,7 +732,7 @@ Outgoing ({outgoingRelations.length})
- {#each outgoingRelations as rel} + {#each showAllRelations ? outgoingRelations : outgoingRelations.slice(0, RELATION_PREVIEW) as rel} {@render relationRow(rel)} {/each}
@@ -652,12 +746,25 @@ Incoming ({incomingRelations.length})
- {#each incomingRelations as rel} + {#each showAllRelations ? incomingRelations : incomingRelations.slice(0, RELATION_PREVIEW) as rel} {@render relationRow(rel)} {/each}
{/if} + + {#if !showAllRelations && hiddenRelations > 0} + + {/if} {/if} {/snippet} @@ -883,48 +990,199 @@ {/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))} + + {#snippet statusContent()} +
+ {#each checks.filter((c) => c.enabled) as check (check.id)} +
+
+ + {checkLabel(check)} +
+
+ + {check.last_run_at ? relativeTime(check.last_run_at) : 'not yet run'} + + +
+
+ {:else} +

+ No checks configured. This entity is unmonitored — its health cannot be known. +

+ {/each} + {#if checks.some((c) => !c.enabled)} +

+ {checks.filter((c) => !c.enabled).length} disabled +

+ {/if} +
+ {/snippet} - {#each sections as section (section.key)} - 0}> - {@render section.content()} - + + {#snippet impactContent()} +
+ {#if blast.length > 1} +
+ {#each blast.filter((b) => b.depth > 0).slice(0, 12) as b (b.entity.id)} + + {/each} +
+ +

+ Reachable by following this entity's own dependencies. Does not yet include things that + point at it. +

+ {:else} +

Nothing downstream depends on this.

+ {/if} +
+ {/snippet} + + + {#snippet activityContent()} +
+ {#if openSignals.length > 0} +
+

+ Open signals +

+ {@render signalsContent()} +
+ {/if} + {#if executions.length > 0} +
+

+ Executions +

+ {@render executionsContent()} +
+ {/if} + {#if tasks.length > 0} +
+

+ Tasks +

+ {@render tasksContent()} +
+ {/if} + {#if events.length > 0} +
+

+ Events +

+ {@render eventsContent()} +
+ {/if} + {#if openSignals.length + executions.length + tasks.length + events.length === 0} +

Nothing has happened here yet.

+ {/if} +
+ {/snippet} + + + {#snippet referenceContent()} +
+
+

+ Relations +

+ {@render relationsContent()} +
+
+

+ Attributes +

+ {@render attributesContent()} +
+ {#if knowledge.length > 0} +
+

+ Knowledge +

+ {@render knowledgeContent()} +
+ {/if} + {#if agentActivity.length > 0 || auditEntries.length > 0} +
+

+ Audit +

+ {@render auditContent()} +
+ {/if} +
+ {/snippet} + + + {@const registry: Record = { + 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} + 0)} + > + {@render section.content()} + + {/if} {/each} {/if} diff --git a/web/src/lib/components/desktop-shell/NewTaskChat.svelte b/web/src/lib/components/desktop-shell/NewTaskChat.svelte index dbadb17..a6f17f4 100644 --- a/web/src/lib/components/desktop-shell/NewTaskChat.svelte +++ b/web/src/lib/components/desktop-shell/NewTaskChat.svelte @@ -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 @@ {:else if id === NEW_TASK_WINDOW_ID} - + {:else if app} {:else} diff --git a/web/src/lib/entityView.test.ts b/web/src/lib/entityView.test.ts new file mode 100644 index 0000000..592dbc4 --- /dev/null +++ b/web/src/lib/entityView.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/web/src/lib/entityView.ts b/web/src/lib/entityView.ts new file mode 100644 index 0000000..5b18de0 --- /dev/null +++ b/web/src/lib/entityView.ts @@ -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 = { + 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 | 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.` +} diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts index 0e1839b..cf36c94 100644 --- a/web/src/lib/stores/windows.ts +++ b/web/src/lib/stores/windows.ts @@ -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)