| 1 | // The app Overview dashboard's numbers, and the Instances tab's one table. |
| 2 | // Both are joins over reads the app pages already do — AppView (routes + |
| 3 | // channels), InstanceMetrics (the shared metrics sweep) and VersionView — |
| 4 | // assembled here in Go so the templates render facts, never compute them. |
| 5 | package console |
| 6 | |
| 7 | import ( |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | // OverviewStats is the Overview dashboard: the handful of numbers an owner |
| 14 | // checks before anything else. Every field is derived from platform state the |
| 15 | // page was already reading — this struct adds no bucket or registry I/O. |
| 16 | type OverviewStats struct { |
| 17 | // Deployment is what this account's routes report serving. Channel |
| 18 | // pointers are desired targets, not proof that a route adopted them. |
| 19 | Deployment DeploymentStatus |
| 20 | |
| 21 | // The app's instances (routes owned by the viewed account), and how many |
| 22 | // of them the last metrics sweep actually saw. The TOTAL comes from the |
| 23 | // routes, never the sweep: a failed sweep costs the readings, and must |
| 24 | // not make the dashboard claim a live fleet has no hosts. Asleep counts |
| 25 | // hibernating non-reporters — expected quiet, not missing. |
| 26 | InstanceTotal int |
| 27 | InstanceReporting int |
| 28 | InstanceAsleep int |
| 29 | // InstanceNoReadings counts instance-kind routes nobody could sample: not |
| 30 | // provisioned, no carlos-app@ backing, no stamped unit. They are honest |
| 31 | // unknowns — the tile names them separately and the verdict must not |
| 32 | // treat them as down (the 2026-08-10 tito false alarm). |
| 33 | InstanceNoReadings int |
| 34 | // InstanceCounted is the Running tile's denominator: InstanceExpected |
| 35 | // minus the unknowable. Templates render facts, never compute them. |
| 36 | InstanceCounted int |
| 37 | |
| 38 | // SidecarTotal is how many of this account's routes are standalone |
| 39 | // workers (Kind "sidecar") — counted apart from InstanceTotal on |
| 40 | // purpose. A sidecar never serves a request and is never static either |
| 41 | // (registry.Route.IsSidecar's doc), so folding it into InstanceTotal |
| 42 | // would make an app with only sidecars read as "static — nothing to |
| 43 | // run" once InstanceExpected (instance-kind only) stays 0, and would |
| 44 | // make a mixed app's tile count a worker as if it were a copy of the |
| 45 | // app process. It has no health rollup here, mirroring InstanceTotal's |
| 46 | // role before InstanceExpected existed — the Instances tab's per-row |
| 47 | // healthWord is where a sidecar's own state (Running/Asleep/Waking/ |
| 48 | // Crashed/Never started) is read. |
| 49 | SidecarTotal int |
| 50 | |
| 51 | // MetricsUnavailable: the sweep itself failed (nil metrics), which is |
| 52 | // "readings unavailable", not "zero instances reporting". |
| 53 | MetricsUnavailable bool |
| 54 | |
| 55 | // InstanceExpected is how many of this account's routes are lifecycle- |
| 56 | // managed processes (kind "instance") — the only kind the metrics sweep |
| 57 | // can ever hear from. Static and service routes are served without a |
| 58 | // tenant process, so they are never "silent": expecting a reading from |
| 59 | // them would flag every static site as unhealthy forever. |
| 60 | InstanceExpected int |
| 61 | |
| 62 | // Verdict is the dashboard's one-word answer to "is this app healthy", |
| 63 | // computed here so templates render it as a fact. Verdict is the CSS |
| 64 | // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the |
| 65 | // words. The rule errs toward "can't tell" over a guessed "all good" — |
| 66 | // same honesty contract as the em-dash. |
| 67 | Verdict string |
| 68 | VerdictLabel string |
| 69 | |
| 70 | // Current resource use summed across every reporting instance. Nil when |
| 71 | // nothing reports — the tiles render the em-dash, never zeros. |
| 72 | CPUSum *float64 |
| 73 | MemSum *int64 |
| 74 | |
| 75 | // DBSizeSum is this app's total on-disk SQLite footprint (each route's |
| 76 | // file plus its -wal, status.BoxMetrics.RouteDBBytes via dbJoin), summed |
| 77 | // across every one of the account's own routes with a sized database — |
| 78 | // not gated on the metrics sweep reporting, since a hibernating |
| 79 | // instance's database is still on disk and still worth showing. Nil |
| 80 | // when no route could be sized. |
| 81 | DBSizeSum *int64 |
| 82 | |
| 83 | // Domains, and how many need someone's attention: a custom domain not |
| 84 | // pointing here yet, or a cert that is not ready. |
| 85 | DomainTotal int |
| 86 | DomainAttention int |
| 87 | |
| 88 | // Shipped releases. The versions list is capped (versionsCap), so |
| 89 | // ReleasesCapped says "20+" rather than lying with "20". |
| 90 | ReleaseCount int |
| 91 | ReleasesCapped bool |
| 92 | LatestVersion string |
| 93 | } |
| 94 | |
| 95 | // assembleOverview derives the dashboard from what handleApp already loaded. |
| 96 | // metrics may be nil (a failed sweep costs the readings and says so, never |
| 97 | // the page or the instance count); versions may be nil the same way. |
| 98 | // |
| 99 | // The tenant boundary is drawn from the ROUTES, on every number here: routes |
| 100 | // are grouped by app NAME across accounts (Assemble's byApp), so both the |
| 101 | // instance count and the domains count must skip rows stamped with another |
| 102 | // account — a same-named app elsewhere must not inflate this dashboard or |
| 103 | // wave its failed cert on it. |
| 104 | func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, versions []VersionView, dbJoin boxJoin) OverviewStats { |
| 105 | st := OverviewStats{MetricsUnavailable: metrics == nil} |
| 106 | st.Deployment = observedDeployment(view.Routes, acct) |
| 107 | reporting := make(map[string]bool, len(metrics)) |
| 108 | for _, m := range metrics { |
| 109 | if !m.Reporting() { |
| 110 | continue |
| 111 | } |
| 112 | reporting[m.Host] = true |
| 113 | if m.CPUFrac != nil { |
| 114 | if st.CPUSum == nil { |
| 115 | st.CPUSum = new(float64) |
| 116 | } |
| 117 | *st.CPUSum += *m.CPUFrac |
| 118 | } |
| 119 | if m.MemBytes != nil { |
| 120 | if st.MemSum == nil { |
| 121 | st.MemSum = new(int64) |
| 122 | } |
| 123 | *st.MemSum += *m.MemBytes |
| 124 | } |
| 125 | } |
| 126 | for _, rt := range view.Routes { |
| 127 | if rt.Account != acct || rt.Platform { |
| 128 | continue |
| 129 | } |
| 130 | if n := dbJoin.dbSize(rt.Host); n != nil { |
| 131 | if st.DBSizeSum == nil { |
| 132 | st.DBSizeSum = new(int64) |
| 133 | } |
| 134 | *st.DBSizeSum += *n |
| 135 | } |
| 136 | if rt.Kind == "sidecar" { |
| 137 | st.SidecarTotal++ |
| 138 | continue |
| 139 | } |
| 140 | st.InstanceTotal++ |
| 141 | if rt.Kind == "instance" { |
| 142 | st.InstanceExpected++ |
| 143 | if reporting[rt.Host] { |
| 144 | st.InstanceReporting++ |
| 145 | } else if rt.Hibernate { |
| 146 | st.InstanceAsleep++ |
| 147 | } else if !expectsReadings(rt.Provisioned, rt.Unit, rt.Backing) { |
| 148 | st.InstanceNoReadings++ |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | st.InstanceCounted = st.InstanceExpected - st.InstanceNoReadings |
| 153 | // Routes and Domains are parallel slices — Assemble appends one of each |
| 154 | // per registry route, in the same loop — which is what lets the domain |
| 155 | // rows be account-filtered here at all (DomainView carries no account of |
| 156 | // its own). Guarded rather than assumed, and the guard fails toward |
| 157 | // counting nothing: a miscount that says 0 invites a look, one that |
| 158 | // includes another tenant's failure does not. |
| 159 | if len(view.Domains) == len(view.Routes) { |
| 160 | for i, rt := range view.Routes { |
| 161 | if rt.Account != acct || rt.Platform { |
| 162 | continue |
| 163 | } |
| 164 | st.DomainTotal++ |
| 165 | if domainNeedsAttention(view.Domains[i]) { |
| 166 | st.DomainAttention++ |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | st.ReleaseCount = len(versions) |
| 171 | st.ReleasesCapped = len(versions) == versionsCap |
| 172 | if len(versions) > 0 { |
| 173 | st.LatestVersion = versions[0].Version |
| 174 | } |
| 175 | st.Verdict, st.VerdictLabel = healthVerdict(st) |
| 176 | return st |
| 177 | } |
| 178 | |
| 179 | // healthVerdict folds the dashboard's numbers into one word. Order matters: |
| 180 | // no owned routes means not deployed; a route with no observed version is |
| 181 | // unknown, never "all good"; and a failed sweep with real instances is |
| 182 | // "can't tell" too. Channel pointer failures do not enter this verdict: |
| 183 | // they obscure a target, not what the routes already report serving. |
| 184 | func healthVerdict(st OverviewStats) (string, string) { |
| 185 | switch { |
| 186 | case !st.Deployment.HasRoutes(): |
| 187 | return "notdeployed", "Not deployed yet" |
| 188 | case st.DomainAttention > 0: |
| 189 | return "attention", "Needs attention" |
| 190 | case st.InstanceReporting+st.InstanceAsleep < st.InstanceCounted: |
| 191 | return "attention", "Needs attention" |
| 192 | case st.Deployment.ReportableCount == 0: |
| 193 | return "ok", "All good" |
| 194 | case !st.Deployment.HasReportedVersion(): |
| 195 | return "unknown", "Can't tell right now" |
| 196 | case st.Deployment.UnknownCount > 0: |
| 197 | return "unknown", "Can't tell right now" |
| 198 | case st.MetricsUnavailable && st.InstanceExpected > 0: |
| 199 | return "unknown", "Can't tell right now" |
| 200 | default: |
| 201 | return "ok", "All good" |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // domainNeedsAttention is the dashboard's one-bit read of a Domains row: |
| 206 | // guidance text means DNS isn't pointing here, and a TLS status other than |
| 207 | // ready/issuing means issuance failed. Issuing is patience, not attention. |
| 208 | func domainNeedsAttention(d DomainView) bool { |
| 209 | if d.Guidance != "" { |
| 210 | return true |
| 211 | } |
| 212 | switch { |
| 213 | case d.TLS == "", strings.HasPrefix(d.TLS, "ready"), strings.HasPrefix(d.TLS, "issuing"): |
| 214 | return false |
| 215 | } |
| 216 | return true |
| 217 | } |
| 218 | |
| 219 | // InstanceRow is one line of the Instances tab's table: a route joined with |
| 220 | // its readings from the shared sweep. The join key is the host — the one name |
| 221 | // owners have for an instance. |
| 222 | type InstanceRow struct { |
| 223 | Host string |
| 224 | Channel string |
| 225 | Version string // what the route reports it is running, "" unknown |
| 226 | Kind string |
| 227 | Status string // registry status; "" means ready |
| 228 | Hibernate bool |
| 229 | Metrics InstanceMetrics // zero-valued (Reporting false) when unswept |
| 230 | |
| 231 | // HealthWord is the Status enum + hibernate + reporting folded into one |
| 232 | // plain word — the column's whole job. |
| 233 | HealthWord string |
| 234 | Restartable bool |
| 235 | } |
| 236 | |
| 237 | // assembleInstanceRows joins the app's routes with the sweep's readings, in |
| 238 | // host order (the routes' own order). Only routes stamped with the viewed |
| 239 | // account join — the same tenant boundary appResourceMetrics draws, kept |
| 240 | // byte-for-byte: rows here and metrics there must cover the same hosts. |
| 241 | func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetrics) []InstanceRow { |
| 242 | byHost := make(map[string]InstanceMetrics, len(metrics)) |
| 243 | for _, m := range metrics { |
| 244 | byHost[m.Host] = m |
| 245 | } |
| 246 | rows := make([]InstanceRow, 0, len(view.Routes)) |
| 247 | for _, rt := range view.Routes { |
| 248 | if rt.Account != acct.Sqid || rt.Platform { |
| 249 | continue |
| 250 | } |
| 251 | status := rt.Status |
| 252 | if status == "" { |
| 253 | status = "ready" // registry contract: empty status means ready |
| 254 | } |
| 255 | rows = append(rows, InstanceRow{ |
| 256 | Host: rt.Host, |
| 257 | Channel: rt.Channel, |
| 258 | Version: rt.Version, |
| 259 | Kind: rt.Kind, |
| 260 | Status: status, |
| 261 | Hibernate: rt.Hibernate, |
| 262 | Metrics: byHost[rt.Host], |
| 263 | Restartable: rt.Restartable, |
| 264 | HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(), |
| 265 | expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)), |
| 266 | }) |
| 267 | } |
| 268 | return rows |
| 269 | } |
| 270 | |
| 271 | // expectsReadings is the one rule for whether silence from a route's process |
| 272 | // is meaningful: the platform runs the process (a provisioned instance or a |
| 273 | // carlos-app@ tenant) or a stamped unit tells the sweep where to look. |
| 274 | // Everything else — a manually-added route whose process some bespoke, |
| 275 | // unstamped unit owns — is honest ignorance, not an outage, and must not |
| 276 | // render red (the 2026-08-10 tito false alarm: 18 serving routes flagged |
| 277 | // "Not responding" because their titogo@* units matched no sweep key). |
| 278 | func expectsReadings(provisioned bool, unit, backing string) bool { |
| 279 | return provisioned || unit != "" || backing == registry.BackingUnit |
| 280 | } |
| 281 | |
| 282 | // healthWord folds provisioning state, hibernation, liveness and whether a |
| 283 | // reading was ever possible into the one word the table shows. Provisioning |
| 284 | // words win while provisioning is real (a cert mid-issue IS the situation); |
| 285 | // once the platform says ready, the question becomes "is the process alive", |
| 286 | // which only instance-kind routes can answer — static and service routes are |
| 287 | // served without a tenant process, so ready means Serving, full stop. Among |
| 288 | // silent instances, expectReadings decides whether the silence is an outage |
| 289 | // ("Not responding") or honest ignorance about a route the sweep was never |
| 290 | // told how to sample ("No readings") — see expectsReadings. |
| 291 | func healthWord(kind, status string, hibernate, reporting, expectReadings bool) string { |
| 292 | if kind == "sidecar" { |
| 293 | return sidecarHealthWord(status, hibernate, reporting, expectReadings) |
| 294 | } |
| 295 | switch status { |
| 296 | case "pending", "creating", "starting", "issuing": |
| 297 | return "Setting up" |
| 298 | case "failed": |
| 299 | return "Failed" |
| 300 | } |
| 301 | if kind != "instance" { |
| 302 | return "Serving" |
| 303 | } |
| 304 | switch { |
| 305 | case reporting: |
| 306 | return "Running" |
| 307 | case hibernate: |
| 308 | return "Asleep" |
| 309 | case expectReadings: |
| 310 | return "Not responding" |
| 311 | default: |
| 312 | return "No readings" |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // sidecarHealthWord is healthWord's vocabulary for Kind=="sidecar" rows, |
| 317 | // split out because "Serving" — the word every other non-instance kind gets |
| 318 | // — is simply false for a sidecar: it is a standalone worker that never |
| 319 | // binds a socket or answers a request (registry.Route.IsSidecar's doc), so |
| 320 | // nothing it does is ever "serving". Its five words: Running (reporting a |
| 321 | // version over the agent socket), Asleep (hibernating, the app manifest's |
| 322 | // choice), Waking (mid-provisioning, mirrors "Setting up"), Crashed (the |
| 323 | // platform runs its process and it has gone silent — mirrors "failed" and |
| 324 | // the instance family's "Not responding", but a sidecar's silence reads as |
| 325 | // a crash rather than a request timing out, since nothing dials it) and |
| 326 | // Never started (nobody has ever run it: not reporting, not hibernating, |
| 327 | // and not yet provisioned to run at all). Every word here is also in |
| 328 | // healthWords (listquery.go), which is what makes it filterable on the |
| 329 | // Running tab. |
| 330 | func sidecarHealthWord(status string, hibernate, reporting, expectReadings bool) string { |
| 331 | switch status { |
| 332 | case "pending", "creating", "starting", "issuing": |
| 333 | return "Waking" |
| 334 | case "failed": |
| 335 | return "Crashed" |
| 336 | } |
| 337 | switch { |
| 338 | case reporting: |
| 339 | return "Running" |
| 340 | case hibernate: |
| 341 | return "Asleep" |
| 342 | case expectReadings: |
| 343 | return "Crashed" |
| 344 | default: |
| 345 | return "Never started" |
| 346 | } |
| 347 | } |
| 348 | |