carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
5package console
6
7import (
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.
16type 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.
104func 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.
184func 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.
208func 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.
222type 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.
241func 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).
278func 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.
291func 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.
330func 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