Skip to content

feat(status): publish per-component service status from the existing alerting stack - #9984

Merged
JSONbored merged 2 commits into
mainfrom
site/public-service-status-9983
Jul 31, 2026
Merged

feat(status): publish per-component service status from the existing alerting stack#9984
JSONbored merged 2 commits into
mainfrom
site/public-service-status-9983

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

The backend slice of #9747, taking option C — source the board from the deployment's own PagerDuty-integrated alerting stack.

Why C is cheap — verified on the box, not assumed

I'd previously called this "the largest change, needs a new egress path." That was wrong, and checking corrected it:

  1. The app already reaches Alertmanager. From inside the running loopover-loopover-1 container: fetch("http://alertmanager:9093/api/v2/alerts")[]. Same compose network. Grafana's /api/health answers too.
  2. The tunnel already routes it. /etc/cloudflared/config.yml:
    - hostname: shots.loopover.ai
      path: ^/v1/public/.*$
      service: http://localhost:8787
    So any new /v1/public/* route is publicly reachable the moment it ships — no tunnel edit, no new hostname. That last part matters: there's no cert.pem on the box, so a new hostname couldn't be minted there anyway.
  3. The alerting is already PagerDuty-integratedalertmanager.yml routes to oncall-pager and team-slack, rules managed in Grafana. Exactly the instrumentation site(status): public service-status surface — ORB and AMS fleet heartbeats and uptime #9747 says to reuse.

Why the alerting stack rather than a new probe

A status-page-only probe is a second opinion about the same components, free to disagree with the one that actually pages a human — and when they disagree, the public page is the one nobody is watching. Reading the alerting stack means the page says what the on-call rotation already believes.

The correctness content: never report green when you couldn't check

A status page that renders "operational" because it couldn't reach its source is worse than no status page — it says the thing is fine at precisely the moment nobody can confirm it. Same false-green class as #9724's all-skip run. Every failure path lands on unknown:

Failure Reported
No alerting source configured unknown — "not configured"
Network error / timeout unknown — "unreachable"
Non-200 unknown — "returned an error"
Body isn't JSON unknown — "unreadable"
Body parses but isn't an array unknown — "unexpected shape"

That last one is the subtle case: {} is valid JSON containing no alerts, and reading it as "nothing firing" would publish green off a payload we failed to understand.

Two more one-way choices:

  • unknown outranks operational in the rollup, so a component we couldn't read is never averaged away by one we could.
  • An unrecognised severity rounds up to degraded. A firing alert with a severity we don't know is still firing; rounding down would hide it.

Public-safe by construction, not by filtering

Alert payloads routinely carry job, instance, pod and other host identifiers. Rather than filtering those out, alerts are mapped onto a fixed component vocabulary and the originals dropped — an unmapped service label is ignored, never published verbatim. Whitelisting the shape is what stops a new alert label leaking through a filter nobody updated. The failure reason is a category, never the configured URL.

A test asserts the serialized response contains none of alertmanager, 9093, instance, job, pod, localhost, cpu, memory, disk.

Deliberately out of scope

Uptime percentages and incident history. Alertmanager's /api/v2/alerts returns active alerts only; there's no Prometheus on the box (metrics go via otel-collector) and no sample table — so there is no honest source for "has it been healthy?" yet. Fabricating an uptime figure from a single live sample is the option this declines. The follow-up is a service_status_samples table on the existing cron, with incidents derived from contiguous non-operational runs.

Also: 404 where no alerting source is configured (the hosted Worker), rather than a board reading "unknown" forever. And a 15s cache, not the sibling surfaces' 60s — this is the endpoint people refresh during an incident.

Verification

  • 29 new tests, weighted toward the failure paths
  • Full suite: test/unit + test/integration1334 files, 25,890 tests, all green
  • typecheck, selfhost:env-reference:check, cf-typegen:check, ui:openapi:check, docs:drift-check, dead-source-files:check, dead-exports:check, import-specifiers:check, coverage-boltons:check, server-manifest:check, ui-derived-types:check, checkers-wired:check, control-plane:openapi:check, contract:api-schemas:check, validate:mcp green
  • route-spec-ratchet satisfied — the route is registered in the OpenAPI document, and route-auth exempts it in the same PR as the route so the two can't drift
  • Generated artifacts regenerated and committed: openapi.json, selfhost-env-reference.ts

Closes #9983

…alerting stack

#9747's backend slice, taking option C: source the public board from the
deployment's OWN Grafana-managed rules -> Alertmanager -> PagerDuty stack rather
than adding a status-page-only probe. A second probe would be a second opinion
about the same components, free to disagree with the one that actually pages a
human -- and when they disagree, the public page is the one nobody is watching.

NO NEW INFRASTRUCTURE, verified on the box rather than assumed. The app
container already reaches Alertmanager on the compose network (`fetch(
"http://alertmanager:9093/api/v2/alerts")` returns `[]` from inside the running
container), and /etc/cloudflared/config.yml already routes `^/v1/public/.*$` on
shots.loopover.ai to this app -- so the route is publicly reachable the moment
it ships, with no tunnel edit and no new hostname. That last part matters:
there is no cert.pem on the box, so a new hostname could not be minted there.

UNREACHABLE IS `unknown`, NEVER `operational`. This is the module's whole
correctness content. A status page that renders green because it could not reach
its source is worse than no status page -- it says the thing is fine at exactly
the moment nobody can confirm it. Every failure path lands on `unknown`: unset
URL, network error, timeout, non-200, unparseable body, and a body that parses
but is not an array. That last one is the subtle case: `{}` is valid JSON with no
alerts in it, and reading it as "nothing firing" would publish green off a
payload we failed to understand. `unknown` also outranks `operational` in the
rollup, so a component we could not read is never averaged away by one we could.

An unrecognised severity rounds UP to degraded. A firing alert whose severity we
do not know is still firing, and rounding down would hide it.

PUBLIC-SAFE BY CONSTRUCTION, not by filtering. Alert payloads routinely carry
`job`, `instance`, `pod` and other host identifiers, so alerts are mapped onto a
fixed component vocabulary and the originals dropped -- an unmapped `service`
label is ignored rather than published verbatim. Whitelisting the shape is what
keeps a new alert label from leaking through a filter nobody updated. The
failure reason is a category, never the configured URL, and a test asserts the
serialized response contains no host, port, instance or capacity token.

404s where no alerting source is configured (the hosted Worker) instead of
publishing a board that reads "unknown" for every component forever. Cached for
15s, not the sibling surfaces' 60s: this is the endpoint people refresh DURING
an incident, and a minute of stale "operational" is the wrong failure.

Uptime percentages and incident history are deliberately NOT here -- Alertmanager
serves active alerts only, there is no Prometheus on the box, and no sample
table, so there is no honest source for "has it been healthy?" yet. Fabricating
one from a single live sample is the option this declines.

Closes #9983
@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui ac6c046 Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:28 AM

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 1.89kB (0.02%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.93MB 1.89kB (0.02%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-C41CU0Ak.js (New) 2.16MB 2.16MB 100.0% 🚀
assets/tanstack-vendor-BR-a4ui-.js (New) 956.74kB 956.74kB 100.0% 🚀
openapi.json 988 bytes 762.02kB 0.13%
assets/docs.fumadocs-spike-api-reference-OjO8yHKT.js (New) 443.45kB 443.45kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-Bx0ZNc3t.js (New) 201.69kB 201.69kB 100.0% 🚀
assets/modal-sG3ie5g-.js (New) 184.5kB 184.5kB 100.0% 🚀
assets/client-UTALB3Yc.js (New) 151.47kB 151.47kB 100.0% 🚀
assets/self-hosting-configuration-CsRfmwI-.js (New) 118.69kB 118.69kB 100.0% 🚀
assets/maintainer-panel-CBRYJB6H.js (New) 78.97kB 78.97kB 100.0% 🚀
assets/routes-BaVs_doJ.js (New) 35.88kB 35.88kB 100.0% 🚀
assets/owner-panel-S-LxwBj6.js (New) 28.26kB 28.26kB 100.0% 🚀
assets/app-DCqbBHhJ.js (New) 25.82kB 25.82kB 100.0% 🚀
assets/ui-vendor-DG_WU-5I.js (New) 24.57kB 24.57kB 100.0% 🚀
assets/fairness-EV7ikZVr.js (New) 23.13kB 23.13kB 100.0% 🚀
assets/miner-panel-BcZI_BQt.js (New) 20.49kB 20.49kB 100.0% 🚀
assets/api._op-CkKGtkv3.js (New) 17.51kB 17.51kB 100.0% 🚀
assets/self-hosting-docs-audit-DQM2Crg_.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs-client-loader-CKlm_ale.js (New) 15.68kB 15.68kB 100.0% 🚀
assets/playground-panel-BVJD1swZ.js (New) 14.35kB 14.35kB 100.0% 🚀
assets/app.audit-oq6jxKQO.js (New) 10.22kB 10.22kB 100.0% 🚀
assets/app.config-generator-Bkhjq-Qv.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-DpfhSlXo.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-B7Tkble-.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-5dwK_j_f.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/commands-panel-KHYnIdO6.js (New) 6.74kB 6.74kB 100.0% 🚀
assets/maintainer-workflow-CXZYmijg.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/docs-nav-UQDtAtB4.js (New) 6.19kB 6.19kB 100.0% 🚀
assets/digest-panel-CXwlcLHu.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-ed7LMFT7.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs.index-Byfwkkdg.js (New) 6.01kB 6.01kB 100.0% 🚀
assets/api.index-MUVRWrXe.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-Dgp5a82h.js (New) 3.01kB 3.01kB 100.0% 🚀
assets/api-DZS-rjE2.js (New) 2.66kB 2.66kB 100.0% 🚀
assets/docs-page-C1ZHGU6v.js (New) 2.14kB 2.14kB 100.0% 🚀
assets/table-DLKvaWai.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-_-djR-Jj.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-D8t4Dj7L.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-B11fX1nj.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-DdOHQKFh.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-d6iDj-EH.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/docs._slug-DziQshoE.js (New) 549 bytes 549 bytes 100.0% 🚀
assets/app.maintainer-7xm85gqw.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-Bg-9PBw2.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-C8Xnv_ee.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-BURcNy3x.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-C3PxttoS.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-D-qEJ8Rf.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-DqfWASOp.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-DKHCF3p8.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-DLEyESct.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-fa4PrsW-.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/git-pull-request-arrow-909fMmdz.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-D1LN8dlz.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-C9iMhW5a.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-BNQx9AvR.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square-D56F96Es.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-CKGQcYk8.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/play-qd-QrZTc.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-CbFRPvZ8.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/add-scalar-classes-DcvxIYlX.js (Deleted) -2.16MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-BA3rS-Mc.js (Deleted) -955.9kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-78Gl3UV8.js (Deleted) -443.45kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-Cp1whQnk.js (Deleted) -201.69kB 0 bytes -100.0% 🗑️
assets/modal-vVY821VG.js (Deleted) -184.5kB 0 bytes -100.0% 🗑️
assets/client-ByY99KGs.js (Deleted) -151.47kB 0 bytes -100.0% 🗑️
assets/self-hosting-configuration-BpnS4-1h.js (Deleted) -118.62kB 0 bytes -100.0% 🗑️
assets/maintainer-panel-lXyHVOTY.js (Deleted) -78.97kB 0 bytes -100.0% 🗑️
assets/routes-f8O783UM.js (Deleted) -35.88kB 0 bytes -100.0% 🗑️
assets/owner-panel-DL0g5So_.js (Deleted) -28.26kB 0 bytes -100.0% 🗑️
assets/app-BhJyXUA8.js (Deleted) -25.82kB 0 bytes -100.0% 🗑️
assets/ui-vendor-BOtqQLaa.js (Deleted) -24.57kB 0 bytes -100.0% 🗑️
assets/fairness-DMHUs3nY.js (Deleted) -23.13kB 0 bytes -100.0% 🗑️
assets/miner-panel-D92AUoqu.js (Deleted) -20.49kB 0 bytes -100.0% 🗑️
assets/api._op-DH7gBuN0.js (Deleted) -17.51kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-CX3WVi-p.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs-client-loader-CDwHm074.js (Deleted) -15.68kB 0 bytes -100.0% 🗑️
assets/playground-panel-Dxhr1BbO.js (Deleted) -14.35kB 0 bytes -100.0% 🗑️
assets/app.audit-DWPK77OC.js (Deleted) -10.22kB 0 bytes -100.0% 🗑️
assets/app.config-generator-DXlPF16i.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-D1KBd6I0.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-BJ_STnlf.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-nuhuuEOk.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/commands-panel-CVumMzxc.js (Deleted) -6.74kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-DgQk1GEa.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/docs-nav-D8dPjj9X.js (Deleted) -6.19kB 0 bytes -100.0% 🗑️
assets/digest-panel-D1YvF5E7.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-DZbubk91.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs.index-DnNV1DX8.js (Deleted) -6.01kB 0 bytes -100.0% 🗑️
assets/api.index-C2Kt6JpH.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-BlJD5nY8.js (Deleted) -3.01kB 0 bytes -100.0% 🗑️
assets/api-BLS0ixvO.js (Deleted) -2.66kB 0 bytes -100.0% 🗑️
assets/docs-page-DSZDbT7n.js (Deleted) -2.14kB 0 bytes -100.0% 🗑️
assets/table-_YNs8cMm.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-DiYSyjx_.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-DMse-DjA.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-DLXf2tTs.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-DSnSqgSy.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-i35fDb3q.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/docs._slug-P9AFQ-46.js (Deleted) -549 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-DjvXUSqz.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-RLRaBzvi.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-C4QH2gJ2.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-CmcW-QwL.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-BgNquQhQ.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-CINjQiuc.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-B6TBbmDt.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner-CxRy7S9A.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-CxlFHPsW.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-DlqVMHvk.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-DTCQx0ot.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-DQbm1Ph9.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-Cd2RvhJq.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-BVYhiqrl.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-BUVJ5MXb.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-mSQ7Oi5x.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/play-DALSi8i2.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-CBmnBpA3.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.01%. Comparing base (910fa59) to head (581cfab).
⚠️ Report is 9 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9984      +/-   ##
==========================================
- Coverage   91.88%   91.01%   -0.88%     
==========================================
  Files         928      929       +1     
  Lines      113722   113772      +50     
  Branches    27436    27455      +19     
==========================================
- Hits       104489   103545     -944     
- Misses       7936     9125    +1189     
+ Partials     1297     1102     -195     
Flag Coverage Δ
backend 94.12% <100.00%> (-1.56%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/routes.ts 95.61% <100.00%> (+<0.01%) ⬆️
src/auth/route-auth.ts 100.00% <100.00%> (ø)
src/openapi/spec.ts 99.27% <100.00%> (+<0.01%) ⬆️
src/selfhost/service-status.ts 100.00% <100.00%> (ø)

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 31, 2026
codecov/patch flagged 94% against the 99% gate. Branch coverage on
service-status.ts was 92.3%, with three arms of the per-component fold
unexercised -- all of them in the paths that decide what a malformed or
partial alert does:

  • a `service` label that is not a string. Alertmanager's contract says
    Record<string, string>, but this reads a parsed JSON body from a source that
    can ship anything, and a non-string must take the same ignore path an
    unmapped label takes rather than throwing on .toLowerCase();
  • a firing alert with no usable `startsAt`. Still degraded -- an alert with no
    timestamp is still an alert -- but no invented `since`;
  • the KEEP arms of the earliest-wins comparison. The existing test put the
    earlier alert second, which only exercises the replace arm; the mirror case
    (earlier first, and a later untimestamped one) leaves half the comparison
    free to invert unnoticed.

Branch coverage is now 100% (39/39), statements and lines already were.
No production code changed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

site(status): public service-status endpoint, sourced from the existing Alertmanager

1 participant