Skip to content

feat(alerts): move active alerts into a header pill & slack cleanup - #918

Merged
illegalprime merged 11 commits into
mainfrom
feat/active-alerts-header-pill
Aug 14, 2026
Merged

feat(alerts): move active alerts into a header pill & slack cleanup#918
illegalprime merged 11 commits into
mainfrom
feat/active-alerts-header-pill

Conversation

@illegalprime

Copy link
Copy Markdown
Contributor

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. AppLayout mounts useActiveAlertsPillData, which gates on the alert:read permission and the runtime alerts probe before useActiveAlertGroups starts polling, and skips the poll entirely on focused detail routes where the header is hidden. Its hasVisiblePill flag 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, ListActiveAlertGroups still returns one row per rule with counts and no device identity. The rollup query is now a CTE plus a LEFT JOIN LATERAL that 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 through visibleSummary with miner:read false — the same gate the history API applies — so a device template's summary is withheld on an endpoint that only holds alert: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:

  • The alerts/enabled probe 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".
  • A permission denial used to stop the active-groups poll for good. It now backs off to a 5-minute retry so a restored grant is picked up without a reload, and overlapping polls drop the older reply via a request counter.

Slack rendering: the header block 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"]
Loading

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 --> [*]
Loading

Areas of the code involved

Area / package / file What changed Why it matters for review
proto/alerts/v1/alerts.proto ActiveAlertGroup.summary added as field 6 Wire-compatible additive field; check the comment's scope claim (device-less only)
server/generated/**, client/**/generated/** Regenerated Go/TS/sqlc generated — skip
server/sqlc/queries/notification_history.sql Rollup rewritten as CTE + LEFT JOIN LATERAL; summary/template bounded, ordering repeated after the join The main query change: index usage, the device_id = '' prefix descent, and the re-applied ORDER BY
server/internal/handlers/alerts/handler.go Populates Summary via visibleSummary(..., false) The authorization boundary — an alert:read-only endpoint must not leak miner-naming text
server/internal/domain/alerts/render.go Title becomes a linked mrkdwn section naming the instance; severity-based dot; MACs as inline code; backtick stripped in escapeMrkdwn; section cap moved into mrkdwnSection Mrkdwn injection surface — check that field-escaping still holds for user-controlled text
server/internal/domain/{notificationhistory,stores/sqlstores} Thread Summary/Template through the rollup row Plumbing
server/cmd/fleetd/config.go FLEET_PUBLIC_URL help text notes its second role No behavior change
client/src/protoFleet/components/PageHeader/* New ActiveAlertsPill, ActiveAlertsPopover, useActiveAlertsPillData; headerWidgetLayout gains an alerts slot The new surface and its visibility gate
client/src/protoFleet/components/AppLayout/AppLayout.tsx Mounts the poll, skips it when the header is hidden Where the poll's lifetime is now decided
client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts Abort deadline, retry with backoff, null for "no answer" Session-long hang fix
client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts Denial backs off instead of stopping; stale-response guard; loading dropped Poll lifecycle under permission changes
client/src/protoFleet/features/alerts/components/ActiveAlertsCard.tsx Deleted, along with its dashboard mount Confirm nothing else referenced it
client/src/shared/components/Popover/* New wide size (2× medium, viewport-capped) Additive shared-component change; no existing size affected

Key technical decisions & trade-offs

  • Summary picked in a lateral, gated in the handler — rather than adding a per-instance endpoint or widening the drill-in. The lateral is bounded to groups that need it, and the gate reuses the history API's visibleSummary instead of trusting device_count = 0 as proof the text names no miner.
  • Bounds in SQL, not in GoLEFT(...) at 500/64 keeps a header-polled one-liner from shipping a whole TEXT column; 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.
  • Denied poll backs off rather than stops — the hook now outlives any route change, so a permanent stop would strand a restored grant until reload. Costs one request per 5 minutes for a site-scoped grant that can't reach the org RPC.
  • New wide popover 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.
  • Instance name from FLEET_PUBLIC_URL's host — reuses the link target already required for notifications instead of introducing an instance-name setting.
  • escapeMrkdwn strips 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.
  • Client: vitest run over PageHeader, 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, null vs disabled), and the alerts API shape.
  • Server: 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.
  • Local Codex security review (codex-security-review-local.sh) — overall risk NONE.

Not covered:

  • Playwright E2E was not run; worth a manual pass on the header pill and drill-in since this changes a shell-level surface.
  • The plugin contract suite was not run — the proto change is alerts.proto, which has no miner-driver surface.
  • Slack output is verified by unit assertions on the Block Kit payload, not against a live workspace.

🤖 Generated with Claude Code

illegalprime and others added 7 commits August 12, 2026 19:29
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>
@illegalprime
illegalprime requested a review from a team as a code owner August 13, 2026 17:17
@github-actions github-actions Bot added javascript Pull requests that update javascript code client server shared review-policy: needs-review Managed by the Review Policy workflow. labels Aug 13, 2026
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated security-focused code review generated by Codex.
It should be used as a supplementary check alongside human review.
False positives are possible - use your judgment.

Scope summary

  • Reviewed pull request diff only (3788362f44cd9fbae7d5f8289d20c2ecb33f5b5b...afa7ab409337c4f68f752f7ca4733dbc962d9261, exact PR three-dot diff)
  • Model: gpt-5.6-sol

💡 Click "edited" above to see previous reviews for this PR.


Review Summary

Overall Risk: NONE

Findings

No concrete security, correctness, or reliability issues were found in the reviewed diff.

Notes

The 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 |
Triggered by: @illegalprime |
Review workflow run

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread server/internal/handlers/alerts/handler.go
@illegalprime illegalprime changed the title feat(alerts): move active alerts into a header pill feat(alerts): move active alerts into a header pill & slack cleanup Aug 13, 2026

@ankitgoswami ankitgoswami left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@github-actions github-actions Bot added review-policy: human-approved Managed by the Review Policy workflow. and removed review-policy: needs-review Managed by the Review Policy workflow. labels Aug 13, 2026
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>
@github-actions github-actions Bot added review-policy: needs-review Managed by the Review Policy workflow. and removed review-policy: human-approved Managed by the Review Policy workflow. labels Aug 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread server/internal/handlers/alerts/handler.go
Comment thread client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts
illegalprime and others added 2 commits August 14, 2026 14:27
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>
@illegalprime
illegalprime merged commit b6b6786 into main Aug 14, 2026
69 checks passed
@illegalprime
illegalprime deleted the feat/active-alerts-header-pill branch August 14, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client javascript Pull requests that update javascript code review-policy: needs-review Managed by the Review Policy workflow. server shared

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants