feat(alerts): move active alerts into a header pill & slack cleanup - #918
Conversation
The dashboard card becomes a pill in the top-right header widgets, shown only while alerts are firing. It carries the triangular alert icon and opens a popover listing the per-rule rollup with the existing affected-miners drill-in. The poll moves to the shell (skipped where the header is hidden, or without alert:read and the runtime alerts probe), so the rollup now feeds every route rather than just the dashboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling in A rule firing on no miner has nothing to list per miner, so its rollup row loses the drill-in and states what fired inline: the rollup carries the newest device-less instance's summary, picked off in a lateral bounded to the groups that need it, and the response still names no device. Rows that do have miners gain a chevron saying so. The drill-in table declares the modal's elevated surface, which its sticky column had been painting with the page background: identical in light mode, two different greys in dark. Rows get the footer link's padding so the hover highlight no longer runs into the text or the dividers, and the footer reads "Configure alerts", which is where it goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rollup rows read as rows, not a paragraph, so they get a panel sized for them: a new `wide` popover size at exactly double `medium`, capped to the viewport since the positioner pins an over-wide panel to the left margin and lets the remainder run off-screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dot was nudged down by a hand-set margin two pixels short of centering it against the 1.5rem heading line. It now sits in a box one line tall and centers itself there, which also holds when a long title wraps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several fleet instances can post to one Slack channel, and nothing in the message said which one fired. The title now carries the host from FLEET_PUBLIC_URL, which is already the link target, so no new config is needed and the name matches where the links go. A value with no host is a misconfigured link rather than an instance name, so it falls back to the bare product name. MACs now render as inline code. That is the only mrkdwn context where Slack leaves :shortcode: alone, and a colon-separated MAC otherwise interpolates 🆎 as an emoji. Backticks are stripped in escapeMrkdwn rather than at the MAC field, since a code span opened in one field closes at the next backtick anywhere in the section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…der poll alive Findings from the local Codex security review of the header pill. The rollup carried a device-less instance's summary verbatim on an endpoint that only holds alert:read. Device-less is not on its own proof the text names no miner, so the query now returns the template alongside it and the handler runs it through the same visibleSummary gate the history API uses. The summary and template are bounded in SQL (500/64) since neither is length-checked on insert and this is a header-polled one-liner; a truncated template matches no known template and so fails closed. Withholding a summary leaves a row that can't say the whole thing, so a device-less group is now drillable when its summary was withheld or when more than one instance is firing, the latter under a pluralized title. Client-side, the probe behind useAlertsEnabled had no deadline: a connection held open without answering left the shared promise pending and the retry loop waiting on it, hiding the alerts surface for the session. A permission denial stopped the poll for good, which no route change could recover now that the hook lives in the shell — it backs off to five minutes instead, and a successful response clears it. Overlapping polls now drop the older reply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🔐 Codex Security Review
Review SummaryOverall Risk: NONE FindingsNo concrete security, correctness, or reliability issues were found in the reviewed diff. NotesThe protobuf change is additive, database access remains parameterized and organization-scoped, and no pool-routing or payout-address changes were introduced. Automated tests could not run because the read-only environment prevented creation of the Go module cache. Generated by Codex Security Review | |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e28203b8a2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The summary gate only allowed the two MQTT templates, so an alert:read viewer saw an empty summary for the facility fan-restore and telemetry poll rules. Both are provisioned, fire on a curtailment event or the fleet rather than a miner, and carry a static annotation summary that names no device. The header pill then marked the lone row drillable and the drill-in redacted the same field, leaving no way to learn what fired. Swap the two-template check for an explicit device-less allowlist. An unlisted template still fails closed, so redaction stays default-deny. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fdbb079f68
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Two follow-ups from review. The ingest-stall rule carried no template label, only component, so the gate read it as unknown and withheld a summary that names the fleet, not a miner. It reaches every org's history by fan-out, so an alert:read viewer got a header row with no count and no text, and a drill-in redacted the same way. Give it a template label and name it alongside the other device-less rules. Adding a label changes the alert's fingerprint, so an ingest stall firing across the deploy resolves once and re-fires under the new one. The poll left denied set when a later attempt failed for any other reason, and the pill hides itself while denied, so a transient error after a denial hid the alerts and the error alike while holding the five-minute retry. Only a permission failure means denied now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f294ee3d2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The effect returned early on a filled cache, but effects run after the render that seeded state, so a probe another consumer completed in that window was never adopted: the initializer's "disabled" outlived the answer and hid the alerts surface for that mount's whole life. Both the navigation gate and the header pill read this hook. Set state from the cache before returning. Re-setting an unchanged value is a no-op, so the ordinary path is unaffected. No test: the failing interleaving needs a render committed before the probe resolves and its effect flushed after, which act() collapses into one step — every reachable variant passes with and without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…effect The previous commit set state directly in the effect body to pick up an answer another consumer had cached, which react-hooks/set-state-in-effect rejects: a synchronous setState there cascades renders. Drop the cached-answer short-circuit entirely instead. The probe already returns a filled cache without issuing a request, so the effect's normal path adopts it from inside the async probe — after an await, so no synchronous setState — and the race the last commit described stays fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewable diff: +546/-228 across 25 files (excludes generated, test, and story files).
Summary
Active alerts move out of the dashboard card and into a pill in the top-right header widgets, so a firing fleet is visible from every route instead of only the dashboard. The pill appears only while something is firing (or while the poll is broken), and opens a wide popover listing the per-rule rollup with the existing affected-miners drill-in. Alert rules that fire on no miner — curtailment sources, ingest stalls — now state what fired inline rather than offering a drill-in with nothing to list. On the notification side, Slack messages now name which fleet instance sent them, since several instances can post to one channel.
How it works
The poll moves from the dashboard card up into the app shell.
AppLayoutmountsuseActiveAlertsPillData, which gates on thealert:readpermission and the runtime alerts probe beforeuseActiveAlertGroupsstarts polling, and skips the poll entirely on focused detail routes where the header is hidden. ItshasVisiblePillflag feeds the existing header-widget layout ladder alongside the curtailment, schedule, and update pills, so the phone-stacked header grows a rung when alerts are up.Server-side,
ListActiveAlertGroupsstill returns one row per rule with counts and no device identity. The rollup query is now a CTE plus aLEFT JOIN LATERALthat picks the newest device-less instance's summary for the groups that have one, bounded in SQL (500 chars for the summary, 64 for the template) since neither column is length-checked on insert. The template rides along because the handler decides from it whether the summary may name a miner: it runs throughvisibleSummarywithminer:readfalse — the same gate the history API applies — so a device template's summary is withheld on an endpoint that only holdsalert:read. A truncated template matches no known template and so fails closed.Because a withheld summary leaves a row that can't describe itself, a device-less group stays drillable when its summary was withheld or when more than one instance is firing, the latter under a pluralized title from a small closed map (only built-in device-less rules roll up more than one instance; user rules fire per miner and are counted as miners).
Two client-side reliability fixes follow from the hook moving into the shell, where no route change can remount it to recover:
alerts/enabledprobe had no deadline. A connection held open without answering left the shared in-flight promise pending forever and every later probe joined it, hiding the alerts surface for the whole session. It now has a 10s abort deadline and retries with backoff to 60s, and distinguishes "no answer" (null) from an answered "disabled".Slack rendering: the
headerblock is replaced by a bold mrkdwn section carrying the title, which lets the instance name be a link.FLEET_PUBLIC_URL's host names the sender and doubles as the link target, so no new config is needed; a value with no host falls back to the bare product name. The title's dot now reflects the batch's worst severity instead of always being red. MACs render as inline code — the only mrkdwn context where Slack leaves:shortcode:alone, so a colon-separated MAC no longer interpolates:ab:as an emoji.Diagrams
Poll and render path:
flowchart TD A["AppLayout (shell)"] -->|"enabled: header visible"| B["useActiveAlertsPillData"] B --> C["useHasPermission alert:read"] B --> D["useAlertsEnabled probe"] C --> E["useActiveAlertGroups poll"] D --> E E -->|"ListActiveAlertGroups"| F["alerts handler"] F -->|"authorize alert:read"| G["ListActiveNotificationGroups"] G -->|"CTE + LATERAL"| H["notification_active"] F -->|"visibleSummary(summary, template, false)"| I["ActiveAlertGroup rows"] I --> J["hasVisiblePill"] J -->|"true"| K["ActiveAlertsPill in header"] K --> L["ActiveAlertsPopover (wide)"] L -->|"device_count > 0"| M["AlertInstancesModal drill-in"] L -->|"device_count = 0, summary shown"| N["inline summary, no drill-in"]Summary redaction decision:
stateDiagram-v2 [*] --> Grouped Grouped --> HasDevices: "device_count > 0" Grouped --> DeviceLess: "device_count = 0" HasDevices --> NoSummary: "SQL CASE drops lateral result" DeviceLess --> TemplateGate: "summary + template (bounded 500/64)" TemplateGate --> Withheld: "device template, no miner:read" TemplateGate --> Shown: "non-device template" Withheld --> Drillable: "row must still be explorable" NoSummary --> Drillable Shown --> [*]Areas of the code involved
proto/alerts/v1/alerts.protoActiveAlertGroup.summaryadded as field 6server/generated/**,client/**/generated/**server/sqlc/queries/notification_history.sqlLEFT JOIN LATERAL; summary/template bounded, ordering repeated after the joindevice_id = ''prefix descent, and the re-appliedORDER BYserver/internal/handlers/alerts/handler.goSummaryviavisibleSummary(..., false)alert:read-only endpoint must not leak miner-naming textserver/internal/domain/alerts/render.goescapeMrkdwn; section cap moved intomrkdwnSectionserver/internal/domain/{notificationhistory,stores/sqlstores}Summary/Templatethrough the rollup rowserver/cmd/fleetd/config.goFLEET_PUBLIC_URLhelp text notes its second roleclient/src/protoFleet/components/PageHeader/*ActiveAlertsPill,ActiveAlertsPopover,useActiveAlertsPillData;headerWidgetLayoutgains an alerts slotclient/src/protoFleet/components/AppLayout/AppLayout.tsxclient/src/protoFleet/features/alerts/api/useAlertsEnabled.tsnullfor "no answer"client/src/protoFleet/features/alerts/api/useActiveAlertGroups.tsloadingdroppedclient/src/protoFleet/features/alerts/components/ActiveAlertsCard.tsxclient/src/shared/components/Popover/*widesize (2× medium, viewport-capped)Key technical decisions & trade-offs
visibleSummaryinstead of trustingdevice_count = 0as proof the text names no miner.LEFT(...)at 500/64 keeps a header-polled one-liner from shipping a wholeTEXTcolumn; migration 000136 bounds only the indexed columns, so this is the first length limit these two see. Truncating the template intentionally fails the visibility decision closed.widepopover size over a one-off width on the pill — a scoped additive variant in the shared component rather than a global token change or an inline override.FLEET_PUBLIC_URL's host — reuses the link target already required for notifications instead of introducing an instance-name setting.escapeMrkdwnstrips backticks instead of escaping*/_/~— a code span opened in one field would close at the next backtick anywhere in the section; the other chars are left so user text can still format itself and interpolate emoji.Testing & validation
just lint— clean (buf lint,eslint --max-warnings 0, golangci-lint 0 issues).just gen— no drift; generated Go/TS/sqlc in the diff match their sources.vitest runoverPageHeader,AppLayout,features/alerts,features/dashboard— 38 files, 266 tests passing. New coverage for the pill, popover, header-widget count,useActiveAlertGroups(supersession, denial backoff),useAlertsEnabled(timeout, retry,nullvs disabled), and the alerts API shape.go test -p 1 -count=1 ./internal/domain/alerts/... ./internal/domain/stores/sqlstores/... ./internal/handlers/alerts/... ./cmd/fleetd/...— all passing, including the sqlstores suite against real Postgres. New assertions cover the rollup summary/template columns, the handler's redaction gate, and the Slack title/MAC rendering.codex-security-review-local.sh) — overall risk NONE.Not covered:
alerts.proto, which has no miner-driver surface.🤖 Generated with Claude Code