From ad29295c937bcdddaf4314b38133168a076aa5a1 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 29 Jul 2026 09:24:28 +0200 Subject: [PATCH] feat(web): make the entity window a triage surface, not a data dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/openapi.yaml | 17 + internal/httpapi/checks.go | 22 +- internal/httpapi/gen/api.gen.go | 367 +++++++++--------- internal/httpapi/metrics.go | 8 +- web/src/lib/api.ts | 21 +- web/src/lib/components/ChatThread.svelte | 10 +- .../lib/components/EntityDetailContent.svelte | 352 ++++++++++++++--- .../desktop-shell/NewTaskChat.svelte | 5 + .../desktop-shell/WindowLayer.svelte | 5 +- web/src/lib/entityView.test.ts | 155 ++++++++ web/src/lib/entityView.ts | 169 ++++++++ web/src/lib/stores/windows.ts | 15 +- 12 files changed, 912 insertions(+), 234 deletions(-) create mode 100644 web/src/lib/entityView.test.ts create mode 100644 web/src/lib/entityView.ts 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", + "H4sIAAAAAAAC/+x963IbudXgq6C4WzXUpClq7JlkI9f3Q5E1thM71lqafJuKXBTYfUhihAZ6ADQlxuWq", + "/NoH2MoT5km2cO1uEk02L7KcqfyxJTUaDZwLcO7nUy/lecEZMCV7p596M8AZCPPjxTWe6v8zkKkghSKc", + "9U57H0DyUqSA5iAk4QxNuEBvJoN3WKWzXtKT6QxyrN9TiwJ6pz2pBGHT3ufPn5NegQXOQbkPnJdCcrH6", + "ifcF/qUElJrHaCJ4jjAqBMwJLyUSIAvOJHwjEYMHNbLDekmP6Hd/KUEsekmP4Vx/PDxsX1bSu2CKqMWb", + "bHUlP/305iXiAklaTlEfjqfH6HbGpTqdlWNB5O2R/2yB1az6Ksl6SU/ALyURkPVOlSihywquaBkBuH3W", + "WMK9PM1xOsgJI7cJuqUP6WmKs2zRth797pYr+lHw/Jrotz9FAaux0gDrhIscq95pL8MKBkq/mkTmfZNB", + "XnAFLF38CRaruz2nBJgaTIGBwAoydAeLF0hAQfFConuiZoShZ9/PkABVCobUDBAXZEoYpoE0PBQsMVeL", + "rn18oL9eX3+OH94Cm6pZ7/S7Z/8ruvSJpfFVDF3jaUWmhAv06uL6Bfr+u2eIs8AnOZG545H44ioe2gZR", + "b0lOVBuWqHlYnyCDCS6p6p3+cJLoPZO8zHunz070b4TZ374LuydMwRSE+dA1X0cPim9PDZ/1Ti3GzHlw", + "CSwjbHpWFILPMdV/SjlTwMz+cFFQkmIN8+HPUgP+U+2D/1PApHfa+x/D6jgb2qdyGCY0n1yitxlmU0BS", + "4SlkLxBGOSg8wO4NdI8lSgUYSuxnJaYDvSLB6VHvc9K7FHxMIV+z0MKO+M12C/bzRtZ7IQQXqP/hx3P0", + "++9/+J1ZxhWZMkx/KjSss4NBzc4aW4P7EpJ+hMe8weLZFJg6SxWZE2UYvBC8AKGIRTJ2T0aWGj71gGma", + "+1tPcU5HKabUMACWnGkq0d9OiWagXtLL02Lk6Q5kiqnZV+/jCmklPaxXMSJZhGmSXsqFAPuyG8JKSvGY", + "gue4lVeyUtjxuVwzPvBL0gNzbHedvrHQ2iyEFaUayTLPsVh0momXattXJEi5BShkmaYg14FhzDkFzPRg", + "xe+AjVJeWnLcDDdDBvZQ6bAWK7R0vHuqY/Vv9opWslejlGSJNiuy4uOfIVX6e/WzaZWuLXutkps9QEZY", + "dV2spfps/TubadbNMe5GBvBQEAFyq2Vakgljy9LCdXnYHWFZndfhAdJSWaYuOCXpYpCag1j/jpUCwQYG", + "Ge0MXuAF5TgisxkRSeOGS8iQnR1lZDJpB1mFX0Hk3Sil2JL3Kuk7CW31gcKqlPUtFvYy01RlaAYyc5Yx", + "Yn6wsLZi4pzfQRbdoyztwtbKhFoCCvdVylkKgsnN5BHjBycnOlJuQMPhMOy0QS4NEl/HNh9KCluxDi4V", + "ZzxfjCjMgdbhq59U14DhB5iDiMLRncX+xmnC8h1eoDEgPJZK4FShPmEzEERJlPF7dtSF0TpywSbiSnkB", + "I7vW9SjXKlcBYmDHIj4HIUgGsstanTgau25iJBGnhSW0VLNuQv65oZOnJ4F9cbOemToBLQqqMiPqgimx", + "2A5EqeKi6/VtBy9LX+YW7CU9/UWsrMq8kAq8kpeVtAWyuwhToDChta1UEDiM2JSDmvFuUxhNuZPYY8we", + "I1J0G22OyVHKM+go9xxAkqkwGxg3TmWWEK9AKT3jCqndWc0cHnBe6DX3ppSPMT3WFDzCqYodbqVVCraS", + "HuaYlnF27H5K3Rk93s60/hw6n0F6t7rZlLMJidhd/oIpsXqOPmrd7RehV43WOhnWhN+O94LemphjOpJx", + "al6WnmZKFXqeVP+bEXmnL2AQamCuZA2OTJCJxlJhJRApZwO7tSgHUyzVaAaYqohx43pGJEo16L6RiN8z", + "lHOpkIAUmEJzEBlJ1TE6Y8hy7jcS2ZkQkUY0uedCKsQn+hcJCKeCS4n09epAZyeXCZIcKf0xItH9DCsE", + "DwXFhEn07f1s8S3C/hN6QAZTgTPIjtGfS0pRyRSh5nNmMjQh+qOiZPJYXxAebmZhBj7udf0jv9cndcnu", + "mP7pY4cr1MBLlMzRehNe/z0DZvdhl6IHIyvvhuX+FzIXlV6gXt9u0n2rLKqwmMIGobFPmFSYpTAwN1vW", + "ScyxE7eIUW52/RD19b9bzUxy4FprjTPAmtMg6f2dsy664hp51/F2jQ3rK6p4vMPx0ibfVIfMuhMkGOfa", + "tOnmSRGG//bkJHmCc6MDBW6iofWUEDb4XXR/HvPrMV1HciveLr1ldxe0bcJT5LJfR++fY4vUMiSZOEve", + "bvJz6u+/lSFje6LhjFgdlijI43Kw+wMWAi/iwt9BrB8dL05nKhgZNGXA0nUHASvzsYV+ZV2MIVZAyvMc", + "mLG+BLDuazkQvFSwrLwMrCxVU2BmnLaYAoyttbOJ7o7QzoMrbt3hCI2rOna3TTvuEqlstBlYT9A5Zwoe", + "VITijdluQijIkbUdRWxBl1jNpBY+zGikLz1RmhUj8yZSWtDwrydbEL4kjtqWpCWSg1Q4L4yOrgUSBg8K", + "FZxSpGEHUrVd+BGFo5CWtKftO7wWJSAyQcd69PEC5/o7KSk07GRtZzHLLKddQGfGDb89lqDK4ljOdodZ", + "7RpfMsFwxhVnJEWpRXdwmjmmTTZpAWsvZkNIV5AKsErWirIjV5f0hk2IJCmmSJoXkR6GsLF8kzEFpJy0", + "mprZt4DDqv4io8t+ieVszLHIripT/hILOMOKHHljY/SyMVIzATkaL0ZaLTVki7OM6K1ietmYc/X1Jeuq", + "FfOMQV9qoECGxgtk501a9Hn3cX/pH/jbTuFd/fRcnxDCbXhpKv3MzzQu0ztQdrIfBjlhpQLkr/CkofDo", + "i7KO6yZC7ETdr7ngFtlA3W5e/0KMWpZ5LVjYD4R2P10c84lVc559P4sholItm+AKWlh8AVodiz7xelz0", + "oVSYRhBu1sfHUuPU7IPTDPQJjVmlN34jtdIJqaaFFFuBIiZ4emVxM+Y6qpwrILPXvcWdVhWd2XFX9PEC", + "GOozzgYCJKdzyI6c93YVn/5zK6ta2toKZ8dOmoD8+JaSyBkWp90GO8cgdsEEp/SDu2NXaG3GpfJexSZs", + "zlJVYor8AGeqQGDmI2yKcpzOCIsycA5y5iyCzUmvbIyUfo7eXBphQAuo5vyaW8OSFZtalapNQUD38pTB", + "/YDiQvHiaKOV0Ey7Dm4uciYmZ40KQeZYweguFrFz9upicHVx/uHievCni78Ojo+PzXYp15dnBqlYFG17", + "NXOXY0rS+NR4Ct/p+ewYTaNm6qv3l1c1KSduUnPX98jez04Wbrvjf2JESxCYnpVq5q509OZlp5mtfLD1", + "7O61GFFZeht5ghkZH/q6D7g3KhKzcgqyL24ijSUsJCsoj4OzHRRxMlPxcBClBBmXqnGOVa/tojy2WTJr", + "VwBkzkqZoHtrr4OacTHnjChu3bPb2A795fPxcC5Do5ibuylqbDSLd/bWeyxRY4s7WxZzrC8ShlkKI2NZ", + "3T0EwR+5rcdc5WOoxVX2WhwqHWNC2mxLO7kotnOXOmOTuw/N7qs5GuTcWE47x7Q6TBt80+a5wFOsdV5D", + "3/oD30gUXhy5MLDIpzdirR07zZW8tAY8abUlQJRMIF2kVC/EGfdGS6rDKh6XdMVSKuOvR1qcCV57qMzP", + "3e7CJpLaEXAZj/k8U4iC4TYWRIYJAZpJlLsVFgIkMHXcS7bA3TsQJhBxvoLDLoj7IpwbR/W1sSJVGLaK", + "AeorgZk0Qmu1p7i40oKAa0cGLTAc1WNb6wv649X7P6MrD6qN9rvGy5FtZ1wDN/qIyJGnw7g5mOIFiLrx", + "LweF7QUqsDVJlUJjZcrnIAz6jLI3ZaQ1/ikAuquZrxWhBRb68vbsttm4aGA6WuuUWY2HMuFcYO7PQkBq", + "QlU/7nXgutPVIcZDuYmOsJKPa+lr4yk7WgnBfgzKCf6OCaYy6gBaoaRDktDOJLP+uI3jaT1CWvwxB8HH", + "rrQZPaLmLs562W+0fUwMVvgRI2LqVoQa7fBe0rvHwpvoBVFano97IIxKGzdwyu4CVQg8CoKfNQwcC0wk", + "ZFuEu7j7u2ZMcEuMklaION3Kd1aLIt8cWOVMGV3Hpw2fXue3uAab2jM0+JH8dltHyXd29AmcR6SlqztC", + "KbJPUX9VZrJP3GFxFJOYBEhz4u7v4XtED50dXLsZNwN2naQu9qWeSLSzi7duhjvbeLG14c/O1meOH83H", + "k0XtZzt4gomNvtDLy0a8NJZwfcNR+3fB9Q+jMU7v3G/6x5F77+M+Ls/aQiKS3dYx1CF4eltnaDi+Wg2c", + "1SlWnawCDLbXc1SEJbBsuzrrNL7EitacbJyDOTeh+5BZF1mfF9ZofdRLtg5Xqqdgbrwc3Fxrox9fCVzM", + "/kLgfhWGkE2hGQCxLkHqg0OgnJEi5oKp7FBtZvsdjUuReNqIm4xkaHBTnpw8h2DrchqptXkRltIyg/+y", + "NGnMR85DDdFIR8azLYDj7H0RsChRstRnqsWd2fpTKMWFWdSMqIj/elnG5Db83WIwhvbXBgatvlTvumhs", + "cMmFEPB5KLytWPcOHQkooBkBRnJ9Fvu8Or1kF+ilzAe2C4PsJv03jS0Ohl08mLINVf9xG7a4DZch7wBY", + "+eWicP8T4/dU881rErlWutqpCbuDbBTloo1xIQKzu06BW+1CDSNFEbtEXpPpjJLpTKPGZF/7CJNOfGUj", + "/jtnCCiiKKzZccWHGU/L3IaNiJKNOb8z1qA5SEWmbTlvm+3NdgExJL/1qv5LfWSvclTdFBs1VGTttsAt", + "sa1A5ERLETu9HKyJEW3g00Tw/BR9UvwUfXKgkqfobwznkA0Mqybo+Pj44+fPnze6t4lPdjP3yoqxuraO", + "GLzfgRIkvQLhIBy5bBZtildu3m2JIqS0LOqUJPB9L+l9N9P/tEQOGmlw3cWG59NO7LdFFm+OHzpNmRPW", + "adw2FoaQVbJUuATfIwsL5LNFNnx2WbiUne6tcOmuE4+uzaBoSMXCKgSOCgLOK0TGFnFpI2G3M24UBSUg", + "22Oxm2G1S2kOhErOEOX3INCYlyxLtMRW2CASmNv3bOL38IdeBKXNMfFbWatwpVg7ZBtHajAQ7CVuFRWs", + "V579UmKBmSKsLTJ8iwzi2aLgagaS/N3mHvjF+0T1JYOluUDCmI/tifvroLmbu7NBSTXd10OqQUormK+p", + "xevCOGvFNpZvr6VswlrWpxBcRG6KHwnQbGDyMGvhOMgOR/3vnz07ao/yM26++PHcpjkvwc7OEMZ3OVV8", + "vs4G2omlGmwSSoLXoYfHvFSnY6rlsVrwQCnIZs3bfGatu+VSqx5yrQ1jnVP73ZuXCUq5AJkggfNRPk5Q", + "RuTdaDpOECkSpCAvqAlGzE1MW4K02E5SkNGgRC4j8uIVLafenXsp+EPOH0xkmAu6qsUoRG0Z8Qiz12WO", + "2UAAzvS5gpw/pGPkl/kufUhPfwZKFxPCtneV04c0QfM8QVygjKd3IEwVG0xYPbS6u7PcAW8DjtsCyqok", + "6m72gxANGDU7BcMYevPSRBkInN6hwi+DsKn+ZSrA2N82XBPR67i3tIS1274KnLi0aX2yRCqdzUFgSu3B", + "g8ikufBg+NzdAtDirD8vhQAWoiZaQzCkgmKd5GjWPcpBSjzt5jyeEEbkbH/782PYsEMAqiiZ84gZxSzg", + "Qd5pNbPlclVQdLCG6FFrT8m1yQKOGT2+LHpiszRskxvO2Y2Oj8rzFz8sXUHAwC6dTbzhtLWxI2smaBNR", + "zeU9MsXottEPSDZSfFfaWcaJhU5SGZ/dWVlb2yYUdYvy6oyY9TbzdnxsfK+bxS8OkE0wiMf5pFhkhGG6", + "5LrmDAaKD7gJy3a/5Jhp4tH/Vc/8b+bhRtt5zPLBtFAK+4XYOEtSpwI0vW0zrze+H4/HqK+p+YWkAfUo", + "3oi8O/ee0HhS0qj6ZIU25hBWFRnxP9pEN5G3nK4hiRVTrXK2KFebcNmCnzh8VjcSWUYMOK5w3iopG0N/", + "R5ev15E6XqpCqpEEYFt56ycUF+uUwRmn2Sjj92zfWMJtq4RVoSFWgB8403dcq99635TcAV2MUlx25Ou8", + "VHvHU/I0NULXeoPHAcJ0NoqClenQBdzg9M77ALxxwXxHb9uqqTZnqBKFPu5yyVcx9jZx8hClynxRsloM", + "kJONVuC9zCZL1BPl5DtC6V42tc2BOCaPdlRZDjq+0bmq3zb2sVLuKVSvCRi0mfok29LgXwieQlaKmPjp", + "ox4zZAThoY0fGfoAkKGPCyooZujD88Hvj1ZCsb3fbhQil9pq8DB9Dnp7ZL7iC9+8kXooUjzuwq27s4/d", + "kOeV1ihi7tMV1W3nqSxcDzFXRBuSIfAnngjZ3V6aCTxxsQs+iCHYSQVMjE22psxtiEP2xlKxPu2ksmfv", + "ZjpdSREJxtNgFK1YoPWMunLK53IUap5jFjGavOLBWGaKB7o4uV68KKirQLnMOESF6mYxd3rGSz3AmJlk", + "XOjqnnciIF4ST+ltqNYUWIoX3UusaKW/GVst5ayX+Ko5Jh+ctVy6bXffFoCOF7757cnG0gdu3UlAd4xK", + "rr1TagmAjOeYLlqkaSKgCirbIYJkVeDkmuOidldiHHOUMMACFYL/bD+doN9lyEb8bfYltvtNJeUxzemt", + "/dyEKFSAQBlebO0UDG66ClrRwAwJaaklFJOQ4qoFABYgzspYruJ7pxYN5wTuQZwiPUxLT3fo/ZuX5+iP", + "/31dj3clbHB2+Qb96x//ROc4yxY3bMLFPRbZAJe2FFsGE2ASBoQNMijULEGM27wwZ73REpoo1ezo+IaZ", + "Et6nxixIUmTXaZNJbZn7KvO0b4p8oVsTKH2r3/Vl4A0xmTcrajesZAqKG5nWVSp3yQ+ukHymuOCrQW3n", + "tuz6QN/lgPRmfYGV9+SOSzTjOVA8Ru+vjpEpWTchFHwNum+/DZu8YWaX336L+qaSO07VwMiFR6foFTce", + "AxBIqnIsERaAqkYE90TNEMcFGehjbwosuWE271Wivv/8+ds3CZqUWipBP72RRxZeBsw4ByQLSI9v2A07", + "52yu0clZTT55fnR6wwbownqh9Nd9mXd021ZU/vZYv/KWSCVRKQHdfjJ3dFLvjfH51i7eNdQo8JQw6/Dq", + "u4MGmUYB6IeTBOX4AT07OTky8/7EJJ4Aunx/dW2LnxQK3S51UbhFfduPoaB4ge4Jy/i9fftdaQ4FJFzL", + "EIlSLMQC3brb7vYFenVx7To5SHR7cY2ntwm6PLs+f418/Aa69Y0RblHftVTwrRTsZ0LNnQpmz58//z36", + "6frcPL9wQUnmKc4yAVKadY2b0aWo3+ztYRB1PQP07vzSlgOZ4BRQXyoBODczvL6+vkwQn0xISjDVBHT1", + "8k9HNoe4ZCYSXaHbYZ4WtzeMs4oQxoRhsUCYZXowL02FRMtLlq41y7ogoRemWqIpw4PuBS5uWEVPVj9G", + "JqUGYemKLALLCk6YkpYfKUnBuWIck13a7G59XAvqGFOeDodO8z52nuShywKv+RF7lt3OLt/UpJbT3nfH", + "J8cnRs0tgOGC9E57z49Pjp9bJ/DMnHdDc0gMcK01gLs0rRGIcPYm6532/ncJYtHsItDsHPO3eAuKWiH3", + "NQ0zWt5tVH7fYYJ66Mbal2OSc7W5Yei70mGs68jRYaTrtNNhpG0n8vnjUmuOZycnWzWWWAoi9GpDJ/2h", + "ifqIPlJv+7N91TKzhMgdvXLl+CUgYMrEcX1OKsEsvoUAs1oLj1okq+2NgcYww3NiamQYMzueSmPTtmGm", + "Y+LNrg8Dv3BbS7N32rPigJl1GEqntHKSvhbOwqhOTBS0jgqXh6to38Y83oqz8sm9+wT86ngjtLJ5OrYI", + "BLU/P2gCRbhGoZ4XqhJA2zDC8BPJPg9DwxgN6ybFb0BwaASmcVy4AJEmS700PTUCGpItv7DU/srSkomG", + "+QPPFnuQUX3TIa3Vsqnl0kXgzKhqxnhL5G9L8ZnX787Oq7YTVjfoS8KmFAalhAT5vCknUg8kyWBzmaKw", + "jTglNhtjfd6TEXftGfXSLRIJSLnIIDvEzWBxZUJ0gC3qsHTQbQVoV5YJXjfLNGVm4/3XyGBmSDfZq156", + "fhfpy/YveEzBq03s42yXN1cS+P4j9O13sVWdL57watOL8OIe6iuJXl5cnR8dgr3NzNvLe02etZXy14p7", + "53ZIJ6ZdEbs60n6I69iBWX0l9ZVXa5l9vy7Ktm0nno6oHUUcSFizTQ0ymBDm0l8qgnYVHjdIbG2SlY2B", + "stB6QrFqIypdrFYneeS7w346il6bOH4A/NqZEHY47tvTZoDlIMMKJ8ibKX931BnnsePLCOn7yua+PEyT", + "hEzVmB0pyDV3fVTSsVVtvrAk20o5vmHo/pRjZzKyK7GmVUdEuxJKozLKhgtvaWw3K0eoY/ClJU5fGX/V", + "1rFVQ4Bf3SXZ7G/xhLflEjkd4Fh1M4LW7KziqGXLGSAXTchLOfBPkFHLkBKY0KNd7SHOKzW0JYwNaqLJ", + "Lr5OpPR1ipPg7pIIM4SngO5gUWAiEtcF2fy9vfBsYjwateTYRtQXb6Q3HKNzTCkIWy8RUwE4W6AZnkO9", + "1RMz1w6DTJ8ujewIE+hlHRzNU8FWND73dfkf4zRvFpv+wgf6UsXmWJNoMyIHpkJLdOsBtE0MWBYQdgD6", + "th9DGDG498WN//WPfyIiZQmehjz91GgnLKGicke4LSRuWxQ2KPyTpOX08zCtmoREwzA+OA/j/YykM9cL", + "xPT/SKxbzZKtKStt+2349hbItPkwRDwlc2BIeV+j8TIz5B3ApsGH7V7GpAKcIT5BU6JQUVIaI9JXoJoN", + "TlburdgWEGd04RYnw+KIrNZlW4M/f/7cBLvF7z6bhblls/aPjymiNCARO5VdW5AMqMIHoNlXoBwZpPWZ", + "HURxBc5tiTPZSaq9ouW09/ljhLJl1bVkurbI+MAEmBjroHnDOpMzX3jXTvuNXDmx2wnTN0x5dLz7D0Xw", + "ftWp98qBVFsPuXVNXr4sMWS+BcywVgwnKgm/ArXSL+YREbfyrZiV3I9BfvH74+k9g4HgJcsGSpDChNRp", + "wSfEAqU2SggJznMTEoQKPIU9fKz1gjatKogPMNl0hv9IqAJh6iPUmyW6QlwS6dHAMsyUbDu8d7Wwh4zB", + "bV8MVVu3ftOX41374hK7l2P70NXq4cwE5gxdkGzsK7/saXr/t9KS2ouqfSHtyNXko0Qe7NSFinmCslMr", + "V7WzIfHCX29fpSWxUdz/C5sSPRW12xJNrbQMbOWMi2s8bZvSDRuaMW7Cg9ggGVpRDjZQRdOC5AcPg8rY", + "rgafO8223gSkUjtdf6t50JVTzqTWz03cp61KoTXnFBc4NSqwj/g+SpA3ZLnZrbuxqk5rIloiOnNTUdbH", + "oN9dcLnHdIpQheDrpv2VOiBfmP5Xa1S0n3SuLGvSRMgvJZRPyiZhCwgj/VqpAun23/6f8wT95V2CQo2P", + "I2QGmqId+/KTN963SaGB9B7R/NF2fDmcuXpAT4edV6GWwXKI8U6X3AHcJMtpD75HR/3UwQIiXUdqnWOq", + "dgEZTF7cMEIpTDFtTGJjudH3J7/Xcq2ZblA9PzpGlzaKb6o/csPsgai10kX16nPU96dcgMtR9LzT29v1", + "rHtkf0+9ecwXtw+2MYjz+FR361NxiHMYVeUtNIPUOsUsdZE5yKk1NF2uB1WX67Yj7A963Ac7rJM3ySTU", + "NPSQAJ7nphQiycu8d/pDJJXrsbWJ5SDBwiYbtTSJ7VyVqa1Qkv3A1mVttojQmUxsnV2PWmvXngpczFBG", + "XI20Qxi1fc6I/yCZWFOQO9gnmFD5RY/zVYL2AWhy84UcCq10o2gBdOdgvioRLsoRvTE3zBIquZjkPmNi", + "ME8+Ht70vI/Kvb7I+850XJ/2EE7GlwboSNSnNX3Pgyetr6GLAnLk0RMSr7VtB5F6WOVyt1HxcoG1R7w+", + "lz8Vwd5l0w8JhTuG3D4OIN9zSuNF7Iypc1no/1KorJmmTTdeuT5++GLu2rN2OHEePe8qahWtlQ35TwDw", + "E9s2585n8lSmzbnN5D1g0O9rIhUXxtkNnhV2dkTMLbRM7mmrO/DKpgZcAVPIbugYXeB0Zr//jUS3JLv1", + "WdG2B77g94hkqC9AljncMHOQ3b7VorKZYfDm5e1Rgm7N6KV3NVATdJthhcOTP169//MNM68iC+1j9Bqw", + "UGPASp9buYGz5rwF+u4HeYz+AFINYDLhwrhhiXnyr3/884aZutKQoQLEQJZjvdMxCDQuJxMQCcoELwac", + "ZiCVS6K+/O3RC5MG/eriGjmY3TDF0RindxMSd8VfGZi2HVatLpwAAVQImJCHfT02VsmqXmygYO0Mm9lW", + "wYOy4BhUFNQ+4aof9uoCuRcPYfafewKyc6L+1dXF0T7MUUVHrfXTVcN2zYV89Aj5ryQj5d/r6ghZok94", + "fVS0dSjHWJ1at44DTFqcHdczQDPMMgpi2TvRDzF8hgaPEhvDK52fYuiLHyY3DLMMAVEzEAiYsYa7ayFU", + "Y+7bcFaXKHyEuKhFEN6wkDnobG/GB+ILQTRnIgzd+vZytyHq74xKjuDB/NUHudiIHsEpmAA0G45lp3v/", + "57d/Rfd4YcdIvcXYVeA8Ehf1tOOv0n243A7uS7sQK45bwwree4L6uS1R6jLIgxPrEELWh0BAdepzpL1A", + "//q//69KU7WJDfpPjmq3CrGtxR9WYzc7RGq09Hgm3074OIRdLIDYtY37DXIdNHc7o/ayJzSRMLQtIR8l", + "6/vcTP30qDwPXS8P4Go3cyGM/OE6DL04Ub3uwo75xfpsFu0Jxhfm8RVAtrcxZ0l0MIWVuI2Va0Lvr2fv", + "3qJa663VGq1Mccqnu7xq78itX1ySN8ICkto+wuRd5BANUTQu9QV/kMPVJwQgqSfWu5G2plXqWgi8/IPv", + "9P/yAxoiVxLIR+I1UsUWUkHeiXiMPX/dqWqaeG5S1q4UFsET26/7YY9eIJ4TZYxp9zMtMFgPQt+2MGqL", + "vhOc7yTUr3EQPdvgIEpMjVJqCi1aibWzvb57bVKpFqa204SLvLcalrfUKfRnTpj3g4zc37T4VvCipEbC", + "Cy1Wj12/x6TLJtxn4nsINRmXOyd03dVjxq9XLWUjHGkeorl5ujdDXpVjS6macudElpiSv7tabqYHKvoN", + "Mj1QdzDva8arepy2cd6PFEC99mh9NJA227VGwGoHHDC22GzMtcr101qzvunohgjL9Fa42MeMFwptDyVg", + "kbZD+so8Dr05uxksfuktKwH7WQG+At2+0Z30cP6310QdQlH/saR0YPJHLDptkdeA5MpL3fcigEyQ6/jZ", + "YNHwylY09Cl4PzrEZNVp6deGzrem/WwF+ENU7KA0yG1y6HGGbKNbpHg0RrUrGuPcbBrNRj1b3ZnaaEa2", + "KO0G79w7N6jTydLZzdbhng8lcw8iqsTlNN8yMxJ/gEvFa/EHzQaqtsXHbgnXuzjxnvJobfSoPRwv2mmR", + "hAPVTDTEajLyBnZOlAfK3fUarit6bafm+0oXOyCKHCNpKG6b92EaJEVYIgTLdZ+w0Q461pG7Fkey5VpX", + "WjptoqwGRKKfbmyxCwkG1B3mhkeeXIxZuZa7pS/z2nL932qrrZGoX1N36rQRM4tBQEDcpF8PqUVYuhTm", + "UV4qpxgEw7vmHDwI5tD7GTBURdiuWMPriTTXVrv8ipNp9AqfMqHGEvvaAj3PTp51oENrJK9X+tzbaKu0", + "AqNmUFGyUWxszn6NoLvTa9NeE6XY4Sd9HcdK/USkHZfit4Wg0xre/hqLDGVAQZkC8IwrJMui4MJUcZ+Z", + "uvCum65E8ECkrVcQ+oGEFH4bU/DyeYQ1aqHnu3HGFwk/10t7whD0No6oFR56Io6oFSwKWK9CJffhBFeV", + "eH0gwqUftI3svUdxzR0jCrrFP/yaIgl8G/2niyMIpHGgKIKiIjVPzxRcJ7nNkoh/+6A12+KdTpDEE1AL", + "NMd0Du7ovXr1u6NjdBYKfOvjvKhLOyuiztX3bYf1ZehF/+VP6iZJtpZa/qXEAjNlGlVFO/Ks9ryqGv7X", + "ml3VOlsZP1IYE1Nrn7bMcmC4r/GWuK4SkrBnJNR39eYBDVEFWzSsbpKj7ry2dHe4OJuQ/FZS6Fbc/0NJ", + "Qfa+grr0eiGHTJMw+zpwnXkkyqZqVrlXdwioOg/KVaNBQfjaCyN3608i1xzSFnHCEwViNa/bn3zPW/Wx", + "Bqi/Uo2svsZtdLInYfNLG//gtKEGlaB+VmI6iDiz19JMB7Z+7Cqo+1HJI2sn/6bkYSjCsfchCcPFVa4z", + "Rp65MVegFGHTpz3rm2s54HEfdneIgut2kUi6OVH/jlA6kPdEpbMEMZiDGPiaq6aizdEOV0Jcpv2AiTRx", + "jn4RRKI6vVDIUP/ZyTP0myoU8hi95fdgih8RZdMf3NLR7ZTyMabHeroRTtUpuunxyeSmd6s1WJzZmEq7", + "pZEfhO7AZVH4a4fkOWQEK6AL/fWTo1NzNdXAYktxmnnQPXbxMZitLzpiTpsYee52bujt6EeYXjZotM0z", + "9Hhy69fJI2cGmzZhRwliQ7WfUEgOx6OndV+dsnFCvmiQ2fsff9QsEQhyv/NTEHk3MAG/G6TlD0Tenbtx", + "T5lS7JdxSEGZyDvkYXAgeVnU59zyaNToaSQj20OSglV9lwv2Zo00627JOaal5LYBL2uTdfaeqRaGt5Uh", + "e4kUv49VsK25mYAdpufQBcu0VFOfui9BSVsGZqS41V1MJAuR6A4KeyPMTF7j4miXshxtatSFa1lpfWiR", + "QjSrfkHUnxEQWKSzxQDfYwFHL1CKRUYYprZt34SLFLI2RWo9zX0dilR9jU/j3GoWQPgi/ScaFOkilnaq", + "/+J7Dqy7FK7cmM4ZgXCwTPPQU5tNeC/p3TtTUdJLBVEkjfYaf5Q8+C6NgH5NZn6L8ye08nuiO1Tl4kDD", + "27XiqfGITaHB6d2j5M+cpXcO5nGsr9+5ffVw7UrO0ipCEzvg7dippAG9vLTSzcHB965UUIPfIZwQeq2j", + "kilCu1aAb+0RudwRv5p5jyaOX5YiNIADKbgSK9fXbw9BFAIkp/PHoYsPdu4Dk0Y7mleQ+VUgz0Ghwl+O", + "WYkpXeyKPq2qbhAa7JBurTGt+WVkok7/471/zGtdY+Upb3VLFYe61M1sqG9SqlRIrCtA2EdHu7n07bSP", + "7X6wqPiKfe0FYQyykYNqvCbiqrtdY6LV1/71edcdQ3z1vnVDk6bBE9G/eqTs6EavUfjQTdXhNP+LH/n1", + "HWA7H0hhT/vjy03lbT+mtqDF2/bH0J55/yYdT3ZNfro2o7c+iv7dkjnMNg9IOg5sh2B0YBnCDNOFJK58", + "IaU+h8NUJo8kUm2T0PGYyVR6K5CWxnSjpx4DFiDOSjXrnf7to8a47cZuP1wK2jvtDXFBhvPvDD24/ay2", + "bXLJ/S7vPOQVmJKzphRO3Xbe3IZNq1mJQ7Gd1yC0fkuq3lZE2jrLhLPEtzmqFY5yvYxW57zYLtXBzcer", + "7ItPcbuH2aIrLtS3qDY+oEZ3zuiCQg2KqreC69SYBC+lRP0MUpLBEKeqNi3USzR9aom7NEsLopc+z2oz", + "hPNt9f26AyZZCjVKgnOsmsp5UVYnChmSjjRcnnBlq6vlOH6Kpl7JxGYsm+9mRCWu+mCCQja+x1SDy2Lg", + "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)