Files
pulse/frontend-modern
rcourtman d5c945241d Show a remote unacknowledge in a tab that acknowledged locally (#2647)
After acknowledging an alert on Alerts > Overview, an unacknowledge from
another session never brought the card back in that tab until a reload,
although `/api/alerts/active` and the store both had it unacknowledged.
A pure remote acknowledge/unacknowledge round trip (no click in the tab)
already worked.

## Cause

`useAlertAcknowledgementState` kept its own acknowledgement override and
spread it over the store alerts. Its release effect never read the
override signal and only re-ran when the `Object.values` memo over the
keyed alert store changed, which in-place store writes (`updateAlert`,
`reconcile` payloads) do not trigger. The override outlived the server's
confirmation and then masked the server's next change. Whether it
cleared depended on unrelated alerts appearing or resolving, so the bug
was intermittent.

The override was redundant: the websocket store's `updateAlert` already
writes the optimistic state and holds back stale payloads until the
server confirms. The hook now writes through it and keeps no copy.
`updateAlert` is required, and its only caller always passed it.

## Store hold fixes (`frontend-modern/src/stores/websocket.ts`)

- **Broadcast before response:** the backend broadcasts after writing
the HTTP response, so the confirming payload can reach the socket before
the response resolves. The hold then waited for a second confirmation,
skipped a following remote unacknowledge, and after 15s showed "Server
did not confirm the alert acknowledgment in time" without actually
re-syncing. Reproduced live. A change the stored alert already shows now
sets no hold and keeps the server's `ackTime`/`ackUser`.
- **Give-up re-sync:** a hold that gives up now re-syncs from
`/api/alerts/active`. If it joins a recovery that the revision fence
then discards, it fetches again, since a quiet estate may send no
further alert delta.
- **Overlapping REST recovery:** a local acknowledgement write
supersedes a REST recovery already in flight, whose response may predate
the write. The store discards that response and fetches again once it
settles.

## Tests

- **Hook (`useAlertAcknowledgementState.test.tsx`), over a real
`createStore` + `reconcile` store:**
  - local acknowledge, then remote unacknowledge
  - local restore, then remote acknowledge
  - acknowledge, restore and bulk acknowledge

  The first two fail on the old hook at the remote-change step.
- **Overview state (`useAlertOverviewState.test.tsx`):** now backed by a
keyed store, and checks that the card returns after a remote
unacknowledge. Fails on the old hook.
- **Store (`websocket-unified.test.ts`):** covers the
broadcast-before-response race, the give-up re-sync, the
overlapping-recovery supersede, a give-up joining a fenced-out recovery,
and a recovery that predates a held acknowledge. The first two fail on
the old store. For the recovery races, removing each mechanism
(supersede, its fresh fetch, the re-sync chain) fails the test that pins
it.

## Browser check

Private mock stack at 1280×900 and 375×812:

- **Before:** the card stayed hidden while the header read "2 critical,
Acknowledged 1" against a nav badge of 3 critical.
- **Reported flow:** a local Acknowledge, then a `curl` unacknowledge,
brings the card back within 2s.
- **Restore direction:** Unacknowledge in the tab, then a remote
acknowledge, moves it back to Acknowledged within 2s.
- **Race:** with the acknowledge response held 1.5s so the socket wins,
the card returns at once with no toast. With only `websocket.ts`
reverted to main, it stayed hidden about 16s and then the toast fired.

## Left as is

If the give-up's own REST fetch fails, the tab keeps the local state
until the next alert payload. That matches main, where a give-up never
re-synced at all.
2026-10-07 11:58:18 +01:00
..