Commit Graph

14903 Commits

Author SHA1 Message Date
rcourtman 630bf897be fix(agent): separate runner trust from collector TLS bypass
Legacy updates recover the collector's insecure setting even after the host
trusts the server certificate. Do not reject verified runner system trust
because of that unrelated setting. Keep HTTPS runner verification enabled
and preserve custom CA and exact fingerprint trust.

Exercise recovered configuration and the real credential lifecycle client
against TLS, including untrusted certificates and mismatched pins that must
receive no credential-bearing request.
2026-10-16 23:22:42 +01:00
rcourtman a93b5be1d3 Fix connection tests ignoring edited SSL settings (#2999)
Testing an existing Proxmox connection used its saved SSL configuration,
so changing certificate verification or the fingerprint had no effect
until the connection was saved. The edit form now sends those draft
values to the saved-credential probe for PVE, PBS and PMG. Explicit
false and an empty fingerprint are preserved, and the probe leaves saved
settings and runtime health unchanged.

The shared modal footer also stacks and wraps its action groups on
narrow screens so the adjacent Pause, Cancel and Save controls remain
accessible.

The canonical and shipped API references document the optional TLS
overrides and fingerprint precedence.

Concurrent chart requests also share one safely published service per
router, including routers initialized lazily. The constructor uses the
same initialization path, and tenant monitors continue to resolve from
each request context.

Validation covers real TLS connections and retained credentials for all
three platforms, 179 focused frontend tests, the full frontend
type-check and production build, the API-reference synchronization
checks, and the canonical governance audits. Browser verification covers
the Infrastructure Manage dialog at 1440×1000, 768×1024 and 390×844,
including pending, success, failure, replacement credentials,
cancel/reload behaviour, and the shared guided/manual setup footers.

Race-detector tests cover concurrent chart service initialization and
tenant-isolated history reads.
2026-10-16 22:04:12 +01:00
rcourtman 5b80ca990f fix(api): initialize chart services once per router
Concurrent history requests could race while publishing a lazily created
chart service. Use the same guarded initialization in the constructor and
handlers so requests share one service without crossing router boundaries.

Add concurrent-call regression coverage and retain tenant-isolation proof
under the race detector.
2026-10-16 21:45:03 +01:00
rcourtman 02ee3a2b1d fix(settings): test edited TLS with saved credentials
Send draft verification and fingerprint settings to the saved-credential
probe so Test connection reflects the edit form before saving. Preserve
saved defaults for callers without overrides and keep probe state temporary.

Keep shared footer actions visible on narrow screens so the edit and test
flow remains usable through pending, error and confirmation states.
2026-10-16 21:17:30 +01:00
pulse-triage[bot] f4d452bf69 Release v6.6.0-rc.3 (#2997)
Release preparation for `v6.6.0-rc.3`. After this merges, the release
workflow runs at this exact head
`524b10f54f23b6ffca71e73762fc2cfb926cae4f`.
2026-10-16 20:01:04 +00:00
pulse-triage[bot] 524b10f54f Prepare v6.6.0-rc.3
Release candidate cut from main at 5a66a7068d. It carries every change merged since stable v6.5.0.

Contract-Neutral: Release-only v6.6.0-rc.3 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
v6.6.0-rc.3 helm-chart-6.6.0-rc.3
2026-10-16 20:44:47 +01:00
rcourtman 5a66a7068d Count successful history reads beyond seven days (#2996)
Measure longer-history use before changing free and paid features.
History and chart APIs count successfully written GET responses only
when they return a metric point older than seven days. Empty, denied,
failed and mock responses do not count. Cached responses and automatic
refreshes do count.

Only bounded daily totals stay locally. Schema v19 exports their rolling
sum with no request, resource, metric, tenant or customer content. The
payload preview and privacy disclosure match. The adoption report
separates schema coverage from zero use and uses each reporting ID's
latest count.

Validation: affected Go packages, receiver telemetry tests, 44 report
tests, schema parity, docs mirror and governance checks. Playwright
exercised /settings/system-general and /docs/PRIVACY, including the
visible counter at 1280x800 and 390x844, refresh and ID rotation.
Receiver deployment and upgraded-client adoption are still needed before
interpreting live usage.
2026-10-16 19:42:27 +01:00
rcourtman 84226ad7bc Count successful history reads beyond seven days
Measure delivered extended history before changing free and paid features.
Count only successful responses containing an older metric point and retain
only bounded daily totals. Expose the rolling sum through schema v19 and
Preview with matching privacy disclosure and explicit report coverage.
2026-10-16 19:21:03 +01:00
rcourtman 6eafe916da Record the bounded unattended recovery comparison (#2995)
Record the completed 36-minute unattended trial per arm after the
provider-failure repair. Pro and free Pulse plus Claude each recovered
all three target outages, including after restart; Claude recovered
faster with lower reported list-equivalent usage in this trial.

Keep the customer-outcome qualification gap and candidate planned. The
live Pro run did not naturally exercise the
empty-response/summary-timeout paths covered by causal regression tests.
Record the experiment limits, retained collector error, unsupported
external reporting claim and completed cleanup. Runtime source is
unchanged.
2026-10-16 17:42:00 +01:00
rcourtman 151f095a2c Record bounded unattended recovery comparison
The provider-failure repair and frozen real-provider comparison now establish a narrower recovery result. Keep the customer-outcome gap planned while recording the observed outcomes, usage, reporting errors and untested production boundaries.

Contract-Neutral: Qualification evidence only; no runtime or storage-recovery behavior changes.
2026-10-16 17:29:32 +01:00
rcourtman 2957a90a93 Keep incomplete investigations eligible for scheduled follow-up (#2994)
Empty model output or a failed final summary could be presented as a
completed investigation and stop scheduled follow-up. Return incomplete
investigations as failures while preserving their receipt; keep the next
scheduled assessment eligible within the existing policy and attempt
limit.

Validation: the new regression tests fail on the original source, then
pass with the fix. Full chat and assessment suites and focused race
tests pass. Persisted failure and genuine human-attention states are
checked in the real Patrol UI at desktop and mobile widths. The frozen
unattended comparison is recorded separately after both fixed windows
finish.
2026-10-16 16:21:23 +01:00
rcourtman 51a45493d1 fix: retain scheduled follow-up after incomplete investigations
Empty model output and summary timeouts previously became successful fallback conclusions, which could stop unattended follow-up. Preserve the readable receipt while returning the failure, and let failed attempts remain eligible at the next scheduled check within existing policy and attempt limits.

Add causal provider, persistence and deadline regression coverage and document the runtime boundary.

Contract-Neutral: Incomplete investigation scheduling does not alter storage-recovery contracts; the owning runtime contract is updated.
2026-10-16 15:59:33 +01:00
rcourtman dc62e93961 Record remaining unattended investigation qualification gap (#2993)
An empty provider response followed by a final-summary timeout can
complete an unresolved investigation as `needs_attention`, preventing
later automatic investigation. Record that remaining failure under the
existing customer-outcome qualification gap after the restart
reconciliation changes in #2989 and rcourtman/pulse-enterprise#121.

The gap stays open and its candidate stays proposed. No lane completion
or readiness state advances.

Validation: normal commit hooks pass, including the 165 release-control
helper tests, governance audits and Go guardrail tests. The original
source fixes retain their separate causal tests and runtime proof.
2026-10-16 15:36:12 +01:00
pulse-triage[bot] 53d97c6407 Release v6.6.0-rc.2 (#2992)
Release preparation for `v6.6.0-rc.2`. After this merges, the release
workflow runs at this exact head
`9ce6614a7f8839120e4eff93bb01adf7fa261f17`.
2026-10-16 14:28:03 +00:00
rcourtman 60c26b5097 Record unresolved unattended provider failure
An empty provider response and summary timeout can complete a still-unhealthy finding as needs_attention and suppress later automatic investigation. Retain this qualification gap so passing restart reconciliation proofs are not mistaken for dependable unattended recovery.

Contract-Neutral: Qualification evidence records an unresolved provider failure without changing runtime or subsystem contracts.
2026-10-16 15:23:24 +01:00
pulse-triage[bot] 9ce6614a7f Prepare v6.6.0-rc.2
Release candidate cut from main at 9f84b9b05e. It carries every change merged since stable v6.5.0.

Contract-Neutral: Release-only v6.6.0-rc.2 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
v6.6.0-rc.2 helm-chart-6.6.0-rc.2
2026-10-16 15:13:51 +01:00
rcourtman 9f84b9b05e Accept PMG 9 cluster ids and stop guessing backup nodes (#2991)
Refs #2960.

## What was wrong

Proxmox Mail Gateway 9.1 users on 6.6.0 betas and rc.1 saw their PMG
connection go degraded, with two errors:

```
pmg_cluster_status failed: json: cannot unmarshal string into Go struct field ClusterStatusEntry.data.cid of type int
pmg_backups failed: API error 500: hostname lookup 'PMG-01' failed
```

1. **Cluster ID format.** PMG 9 sends the cluster status `cid` as a
string. Pulse decoded it into an `int`, so the whole cluster read failed
and the node list (queues, backups) was lost. Pulse never reads `cid`,
so `ClusterStatusEntry` no longer decodes it.
2. **Guessed backup node.** With no node list, the poller asked for
backups from a node named after the connection's display name
(`PMG-01`). PMG rejected that with a hostname-lookup error, which read
like a DNS problem. That fallback now runs only when PMG answers
successfully with an empty cluster list, which is the case some
standalone installs rely on. After a failed cluster read it is skipped.

Skipping the guessed request changes no data. That request could never
return backups, so the published backup list was empty either way. The
poll still records the cluster-read failure.

## Verification

- **New tests**, which fail against `main`:
- `TestClientClusterStatusIgnoresClusterIDFormat`: `cid` as `1`, `"1"`,
`"node-a"` and `null` all decode.
- `TestPollPMGInstanceAcceptsStringClusterID`: the reporter's payload
populates node `pmg-01` and reads backups from `/nodes/pmg-01/backup`
only.
- `TestPollPMGInstanceDoesNotGuessNodeAfterFailedClusterRead`: after a
failed cluster read, no backup request is sent.
- **Unchanged behaviour:**
`TestPollPMGInstanceKeepsNameFallbackForEmptyCluster` passes on `main`
and with this change. An empty cluster still falls back to the
connection name.
- **Suites:** `go test ./pkg/pmg/` passes, and so does all of
`./internal/monitoring/` (433s). `go vet` and `gofmt` are clean.
- **Review:** an independent review found no other reader of `cid`, and
no other PMG integer field that isn't already tolerant of strings.
- **Not tested here:** there is no PMG 9 in the lab, so this hasn't run
against a real PMG 9.
2026-10-16 14:38:23 +01:00
rcourtman 36c63bbf8b Accept PMG 9 cluster ids and stop guessing backup nodes
Proxmox Mail Gateway 9 returns the cluster status cid as a string, so
decoding it into an int failed the whole cluster read and the poller
lost the node list (issue 2960). Pulse never reads cid, so the field is
no longer decoded and any format works.

With no nodes, the poller then requested backups from a node named
after the connection's display name ("PMG-01"), which PMG rejected with
a hostname-lookup error that pointed users at DNS. That fallback now
runs only when PMG answered with no cluster entries; after a failed
cluster read the request could never return backups, and the poll
already records the cluster failure.
2026-10-16 14:15:58 +01:00
rcourtman 6a7f516c41 Give Alert History's free width to the message (#2990)
## What changes for users

**Alerts → History**, on laptop and desktop widths:

- **Message gets the room.** Message says what happened, yet it was the
narrowest column. The table sized every column as a share of its card,
so extra width went mostly to a status badge, a duration and three
buttons that already fit. Now those columns get exactly the pixels their
content needs, and Message takes the rest.
- **No sideways scroll in Safari.** Between an 800px and a 1024px card
(roughly 1280–1480px windows), WebKit kept the width of the hidden Type
and Node columns, so the table overflowed its card by about 190px.

Measured on the same 79 history rows. "Before" applies `main`'s column
values to the same page:

| Window | Card | Message before → after | Message cells cut off |
WebKit horizontal overflow |
| --- | --- | --- | --- | --- |
| 1280 | 835 | 154 → 180px | 20 → 8 | 1027px table → none |
| 1440 | 982 | 182 → 310px | 10 → 5 | 1208px table → none |
| 1600 | 1134 | 215 → 278px | 8 → 5 | none → none |
| 1920 | 1436 | 398 → 457px | 8 → 5 | none → none |

Chromium matches these numbers, apart from the overflow column (it never
overflowed). Rows keep one height per density (49px, or 39px from an
80rem card), which the windowed list needs. On a phone, History still
shows its card list.

## How (`index.css`)

From an 800px card:

- **Fixed pixels** for Time (84), Severity (86), Duration (91), Status
(119, for "acknowledged"), Actions (177) and Type. Type is 120px, and
128px from a 1280px card: "Container Health" clipped at the old 108px
floor.
- **A plain share of the card** for Resource and Node, which hold
truncating names. Each stays at or above its header width at the
narrowest card of its density.
- **No width** for Message, so it absorbs the rest.
- **Hidden columns** are collapsed and also zero-width, and the table is
always 100%.

Two alternatives didn't work, both measured:
- `display: none` on a `<col>`: Chromium lays it out as an auto column
that splits the free width with Message (about 65px each at 1280).
- `max(93px, 10%)` on a column: also laid out as auto.

## Verification

- **`App.architecture.test.ts`, rewritten for the new model:**
  - The table is 100% at every density, with zero-width hidden tracks.
  - Message is `auto` from 800px.
- Every column's pixel floor holds at the narrowest card of its density.
  - Message is at least 18% of the card there.
  - Message gets at least 75% of each density's extra width.
  - Four of these fail against `main`.
- **Suites:** the app, pages, alerts and shared suites, 3005 tests, all
pass.
- **Static checks:** tsc, the copy, canonical-platform and form-label
audits, and the bundle check are clean.
- **Live, Mac Chromium:** the in-app browser at 1280, 1440, 1484, 1600,
1920 and 375 widths. WebKit 2215 and Chromium measured on pulse-dev
against the same stack.
- **Known limit:** at 1600px six Type cells clip with the Linux fonts
used on pulse-dev, against two before. With Mac fonts none clip. 120px
is the most Type can have at a 1024px card while Message keeps 18%.
- **Review:** an independent review pointed out the Safari risk.
Measuring it found the overflow on `main` that this fixes.
- **Receipt:** the browser receipt is included.
2026-10-16 13:18:59 +01:00
rcourtman fce8926a43 Reconcile uncertain restarts and keep Patrol follow-up available (#2989)
When a Docker restart lost its acknowledgement, Pulse stopped before
reading the container back and then treated inconclusive verification as
a terminal investigation outcome. Reconcile using reads within the
original deadline, keep execution acknowledgement separate from
independently observed health, and let a fresh Patrol assessment hand
unresolved findings to investigation after Watch completes.

Validation: causal regressions fail on the previous source. Focused race
checks pass for lifecycle execution, action results and persisted
follow-up. Full host-agent and assistant suites pass. Native restart
checks cover normal acknowledgement and a natural transport timeout,
including confirmed health without inventing execution success. Action
history was exercised at1440×1000 and390×844, including evidence
expansion, dismissal and reload.

The paired private investigation change retains the exact previous
action receipt in follow-up context. The unattended comparison uses a
corrected shared Docker proxy deadline and unchanged goals, permissions,
cadence, faults and fixed cutoffs.
2026-10-16 13:18:14 +01:00
rcourtman 31c799fbc8 Give Alert History's free width to the message
The History table sized every column as a percentage of the card, so
as the card grew most of each extra pixel went to a status badge, a
duration and three buttons that already fit, while Message, the column
that says what happened, stayed near 18%: 182px of a 982px card at a
1440px window. In Safari the 800-1024px card density also scrolled
sideways: WebKit keeps a collapsed column's width in a fixed-layout
table, and the table was widened by the hidden columns' share.

From an 800px card, columns with fixed-size content now take exact
pixels, resource and node take a plain share of the card, and message
takes the rest. Hidden columns are collapsed and zero-width with the
table at 100%, so WebKit and Chromium lay it out the same. Type gets
120px (128px from a 1280px card) because "Container Health" clipped at
the old 108px floor.

Measured on 79 rows in WebKit and Chromium: message 154 -> 180px at
1280, 182 -> 310px at 1440, 215 -> 278px at 1600, 398 -> 457px at 1920,
no horizontal overflow anywhere, rows still one height per density.
2026-10-16 12:45:52 +01:00
rcourtman 2214d270f3 fix: reconcile uncertain restarts and preserve Patrol follow-up
A lost restart acknowledgement can leave the container recovered while the
action remains uncertain. Read back within the original lifecycle deadline,
keep health confirmation separate from execution acknowledgement, and let a
fresh Patrol assessment hand unresolved findings back to investigation.

Contract-Neutral: Docker lifecycle reconciliation does not alter storage-recovery contracts. Owning agent, API and runtime contracts are updated.
2026-10-16 12:45:45 +01:00
rcourtman 7c136b646c Say delivery is turned off, not never turned on (#2988)
## What changes for users

When notifications are paused, the banner on the Alerts overview and on
the Notifications tab began:

> Notification delivery has not been turned on for this install yet.

That's also what you saw after turning delivery on and then deliberately
switching it off. Switching it off stores the same "pending" state as a
fresh install. The banner now begins:

> Notification delivery is turned off.

That's true in both cases. The rest of the banner is unchanged: Pulse
keeps detecting, and test sends bypass the pause. The **Turn on
delivery** button is unchanged too. German and Spanish get the same
change.

## Why not tell the two cases apart

I first tried to distinguish them with `activationTime`, which only
activation sets. An independent review found that this doesn't prove the
history:
- An imported config can carry another install's `activationTime`
without delivery ever being turned on locally
(`internal/config/export.go`).
- The activation API can enable delivery without recording a time
(`internal/api/alerting/alerts.go`).

So the banner claims only what the state proves. The hook gets a comment
saying why.

## Verification

- **Tests:**
- The presentation test pins the new English cause, and checks that
German and Spanish no longer say "not yet".
- `i18n.test.ts` checks that no locale's cause for pending delivery says
"yet" or "not yet".
- `OverviewTab.emptystate.test.tsx` renders the banner through the real
activation store and hook for a fresh install and for a switched-off
install. Both read "turned off" with no "for this install yet".
  - All four fail against `main`.
- **Suites:** alerts, pages, i18n and the presentation tests, 2572
tests, all pass.
- **Static checks:** tsc, formatting, the copy, canonical-platform and
form-label audits, and the bundle check are clean.
- **Live, private mock stack:** delivery was turned on at 10:28 and then
switched off. Both banners now read "Notification delivery is turned
off." At 375px the banner fits with no sideways scroll.
- **Receipt:** the browser receipt is included.
2026-10-16 11:58:53 +01:00
rcourtman 4a13c65443 Say delivery is turned off, not never turned on
The Alerts "Notifications are paused" banner said "Notification delivery
has not been turned on for this install yet" whenever delivery was
pending. Switching delivery off writes that same pending state, so
someone who had paused it deliberately was told it had never been on.

The config cannot tell the two histories apart: activationTime can
arrive in an imported config without local activation, and the
activation API can enable delivery without recording one. So the banner
now says only what the state proves, "Notification delivery is turned
off.", in English, German and Spanish, on the overview and the
Notifications tab.
2026-10-16 11:38:13 +01:00
rcourtman 3609f86ac5 Show a PBS server's host stats once in Backups (#2987)
## What changes for users

**Proxmox → Backups → Backup server** has one row per server and
datastore. A PBS with two datastores used to look like two identical
servers:

| Before | Status | CPU | Memory | Uptime | Datastore |
| --- | --- | --- | --- | --- | --- |
| backup-vault | Healthy | 10.7% | 35.8% | 30d | offsite-vault |
| backup-vault | Healthy | 10.7% | 35.8% | 30d | primary-vault |

Now the host figures appear once, on the server's first row. Later
datastore rows show the server name muted and only their own datastore,
used space, backup count and dedup:

| After | Status | CPU | Memory | Uptime | Datastore |
| --- | --- | --- | --- | --- | --- |
| backup-vault | Healthy | 10.7% | 35.8% | 30d | offsite-vault |
| <sub>backup-vault</sub> | | | | | primary-vault |

Unchanged:
- Every row keeps its own detail toggle and web-interface link.
- Capacity evidence works as before. An offline server's later datastore
still reads "Unavailable".
- Servers with one datastore render exactly as they did.

## How

`buildBackupServerRows` marks a server's later datastore rows
`continuesServer`. Rows are already grouped: servers by name then ID,
datastores by name. In those rows the table leaves the status, version,
CPU, memory and uptime cells empty and mutes the name. Each row still
carries the host readings, so drawers and identity are untouched.

## Verification

- **Tests:**
- Model: the flag is true only for a server's later datastore rows,
across several servers and a reachability-only row.
- Render: the second row of a two-datastore server has empty host cells
and keeps its name and datastore figures.
  - All three fail against `main`.
- **Suites:** the Proxmox and platform-page suites (1017 tests) pass,
including the existing drawer, web-interface and capacity tests.
- **Static checks:** tsc, the copy, canonical-platform and form-label
audits, and the bundle check are clean.
- **Live, private mock stack:**
  - At 1024px backup-vault reads as one server with two datastores.
  - The second row's toggle opens the backup-vault drawer.
- At 375px (compact layout) the rows read the same way, with no sideways
scroll.
- **Review:** an independent review raised windowing. Above 140 rows (36
on a phone) the table mounts only a slice, so a server's first row could
be unmounted while a later row is mounted.
- The window follows the scroll position (`usePlatformWindowedItems`),
so that only happens after the first row has already scrolled out of
view.
- That's no different from scrolling past it in an unwindowed table, so
I made no change for it.
- **Receipt:** the browser receipt is included.
2026-10-16 11:20:24 +01:00
rcourtman 2c86edaf7f Name the notification switch the same in both states (#2986)
## What changes for users

The switch in the Alerts overview header turns outgoing notifications
(email, webhooks, Apprise) on and off. Its label used to change with its
own position:

| Switch | Old label | Reads as |
| --- | --- | --- |
| on | Notifications enabled | fine |
| off | Notifications paused | "paused: no", which is the opposite of
the truth |

The paused banner below the switch said the reverse, so you had to
reconcile two signals. Getting it wrong means expecting alerts that are
never sent.

The switch is now labelled **Send notifications** in every state (German
"Benachrichtigungen senden", Spanish "Enviar notificaciones"). The
switch position alone says whether delivery is on. Clicking the label
text toggles it, and screen readers announce "Send notifications,
checkbox, checked/unchecked".

Unchanged:
- The paused banner, which explains that Pulse keeps detecting alerts
while delivery is off.
- The pause toast ("Notifications paused. Pulse will keep detecting and
showing active alerts.").
- Everything else on the page.

## How

- **Catalogs:** one new key, `alerts.activation.switchLabel`, replaces
`alerts.activation.label.enabled/disabled` and the sr-only
`alerts.nav.toggleAlerts` in en/de/es.
- **`alertActivationPresentation.ts`:** returns the fixed label and no
longer returns a state colour.
- **`Alerts.tsx`:** one `<label>` wraps the text and the checkbox, so
the visible text is the accessible name. The track is a `span` inside
the inline-flex wrapper, which keeps the same size and stays valid
inside a label.
- **Docs:** `TROUBLESHOOTING.md` and `WEBHOOKS.md`, plus their public
mirrors, now say "turning off the global **Send notifications** switch".
The troubleshooting guide also wrongly placed it under Alerts →
Notifications; it's on the overview.
- **Spec 01:** finds the switch by its new name and checks the restore
step by the unchecked state.

## Verification

- **Tests:**
- `Alerts.readOnly.test.tsx`: the switch is named "Send notifications"
when on and when off, with no "Notifications paused" or "Notifications
enabled" text.
  - `i18n.test.ts`: the label has no state word in any locale.
  - Presentation and guardrail tests updated.
  - Six assertions fail against `main`.
- **Suites:** alerts, pages, i18n and utils, 2588 tests, all pass. The
notification docs test passes (16 tests).
- **Static checks:** tsc, the copy, canonical-platform and form-label
audits, and the bundle check are clean. Spec 01 parses. Its formatting
differs from the frontend formatter config in the same way `main`'s copy
does.
- **Live, private mock stack:**
- Off: the label sits beside an off switch, and the paused banner is
shown.
- Clicking the label turned delivery on: switch on, banner gone.
Clicking again turned it off and showed the pause toast.
  - At 375px the control fits the header and there's no sideways scroll.
- **Review:** an independent review found that spec 01 still used the
old accessible name; it's fixed here.
- **Receipt:** the browser receipt is included.
2026-10-16 10:58:02 +01:00
rcourtman b3d6540842 Show a PBS server's host stats once in Backups
The Backups "Backup server" table has one row per server and datastore,
and every row repeated the server's status, version, CPU, memory and
uptime. A PBS with two datastores therefore showed the same server twice
with identical figures, which reads as a duplicated server rather than
one server with two stores.

Later datastore rows of a server now keep its name (muted), their own
detail toggle and web link, and their datastore figures, but leave the
host cells empty; the first row carries them. Row identity, drawers and
capacity evidence are unchanged, and single-datastore servers render as
before.
2026-10-16 10:57:16 +01:00
rcourtman 200caca27c Resume Patrol's persisted schedule after restart (#2985)
Restarting Pulse discarded Patrol's pending deadline and started a fresh
interval after startup. A restart with five minutes remaining on a
ten-minute schedule could postpone the next check by another ten
minutes.

Persist the deadline and effective interval in the existing Patrol
history envelope, resume the remaining wait after startup readiness, and
run an overdue check once. Explicit interval resets remain durable.
Deadline and history writes preserve each other's records and daily
tallies.

Validation: the real-loop regression fails before the fix. Focused race
checks and the full AI/config packages pass. Simulated-time cases cover
repeated restarts, due and overdue checks, interval changes and first
startup. Persistence tests cover file reconstruction, failed-write
retry, corrupt-history protection and concurrent writers. Native runtime
and desktop/mobile browser checks preserve the exact deadline across a
restart, including an explicit interval change, without adding a
duplicate run.
2026-10-16 10:46:28 +01:00
rcourtman aae71f50e2 Name the notification switch the same in both states
The Alerts overview header switch was labelled "Notifications enabled"
or "Notifications paused" depending on its own position. Beside an off
switch, "Notifications paused" reads as "paused: no", the opposite of
the truth, which can leave someone expecting alerts that are never
sent. The paused banner below said the reverse, so the reader had to
reconcile two signals.

The switch is now labelled "Send notifications" in every state (German
and Spanish too), and that visible label names the checkbox, so the
switch position alone carries the state. The paused banner and the pause
toast still say Pulse keeps detecting alerts, which keeps detection and
delivery distinct as the 2026-07-18 change intended. The sr-only
"Toggle external notifications" name and the two state keys are gone,
spec 01 finds the switch by its new name, and the troubleshooting and
webhook guides name the switch instead of the old state label.
2026-10-16 10:37:14 +01:00
rcourtman c38545226a Resume Patrol's persisted schedule after restart
A restart discarded the next check time and began a fresh interval after
startup, postponing checks even when a recent full run was retained.
Checkpoint the deadline and its interval alongside run history, then resume
the remaining wait after the normal startup grace. Coalesce overdue checks
into one attempt and retain explicit interval changes across restarts.
Serialize deadline and history updates so neither write discards evidence.
2026-10-16 10:21:27 +01:00
rcourtman 5fbceb4c5a Keep historical fix results from closing recurring findings (#2984)
A verified action from an earlier investigation could close a newly
reopened failure sharing the same finding ID. Opening the finding's
investigation could trigger that projection before the next
investigation started, leaving a recurring failure without fresh work.

Bind outcome and record reconciliation to the current session and
persisted recurrence boundary under the findings lock. Finding-scoped
investigation reads now return only current work. Earlier investigations
and their verified actions remain available as history.

Regression coverage reproduces the failure before the fix and exercises
three occurrences, persisted restart, duplicate callbacks, stale reads
and concurrent re-detection. Validation includes the full AI package,
the wider API run, focused race checks and live desktop/narrow review.
The wider API run exposed an archive-only version-test failure,
reproduced on unchanged source and passing for both baseline and fixed
real checkouts. Full required source checks run from a checkout before
merge.
2026-10-16 01:08:39 +01:00
rcourtman c1b5f4b13f Keep historical fix results from closing recurring findings
A review of an old verified action could close a fresh failure sharing the
same finding ID before its next investigation started. Bind action outcome
and record reconciliation to the current session and persisted regression
boundary. Keep finding-scoped reads current while retaining historical
session and action evidence.
2026-10-16 00:42:22 +01:00
rcourtman 5f3a2ffd5b Order the endpoint step as address, then name (#2982)
## What changes for users

#2979 moved **Endpoint URL** ahead of **Node Name** in the Add Proxmox
VE/PBS/PMG form. The step summary above the form still read **Endpoint —
Name and API address**, the old order. It now reads **Endpoint — API
address and name**.

## Verification

- **Tests:**
- `NodeCredentialSlot.test.tsx` checks the rendered step text in the
endpoint-first test.
  - `settingsArchitecture.test.ts` pins the source copy.
  - Both fail against `main`.
- **Suites and static checks:** Settings plus the boundary and
presentation suites, 2463 tests, all pass. tsc, formatting, the copy,
canonical-platform and form-label audits, and the bundle check are
clean.
- **Live, private mock stack:** at 1024px the sequence reads "1 Endpoint
API address and name". At 375px the step text fits on one line and
there's no sideways scroll.
- **Receipt:** the browser receipt is included.
2026-10-15 23:39:50 +01:00
rcourtman 2adadf7c1c Order the endpoint step as address, then name
PR 2979 moved Endpoint URL ahead of Node Name in the add-node form, but
the connection sequence above it still summarised the first step as
"Name and API address", so the summary asked for the fields in the old
order. It now reads "API address and name".
2026-10-15 23:18:24 +01:00
rcourtman de7fc078fc Fix Docker child aliases that block command host routing
Docker child inventory attached the reporting host's aliases to each child, so a log request for a unique host could be refused as ambiguous even with a connected command agent. Keep each child's identity separate from its placement and return a strict resolution error for genuine collisions.

Validation: full unified-resource package and 1,680 tool tests passed. Restoring the original identity implementation with the final regression fixture reproduces the hostname failure. Frontend type-check, production build, and governance audits passed.

A disposable live canary read logs through the command agent, automatically restarted the affected container under its allowlisted policy, and confirmed healthy state through daemon readback and a separate HTTP check. Browser verification covered the Patrol Assistant, host/resource picker, retained conversation, and real investigation thread at 1440×1000 and 390×844.
2026-10-15 22:22:33 +01:00
rcourtman 8bab8328ad Fix Docker child aliases that block command host routing
Docker children indexed the reporting host's identity as their own, making
an otherwise unique command target ambiguous after inventory ingestion.
Keep child identity separate from placement and report genuine ambiguity
as strict resolution failure instead of a missing agent connection.

Cover all Docker child kinds, registered log reads through collected
inventory, and refusal of real collisions without hostname fallback.
2026-10-15 21:55:31 +01:00
rcourtman 677dc19029 Ask for the node endpoint before its name (#2979)
## What changes for users

**Settings → Infrastructure → Add Proxmox VE** (and PBS/PMG, which share
this form) used to open on **Node Name**. It was marked required with a
red asterisk, and its help contradicted itself:

> Required and must be unique. We can auto-fill it from the Endpoint URL
if you leave it blank.

Both halves were wrong:
- Typing the endpoint already fills an empty name.
- Every save falls back to a derived name. Assisted setup uses the name
the host reports.
- The server keeps duplicate names distinct by adding the host IP
(`disambiguateNodeName`).

So a new user met a contradictory "required" field before the one input
that actually matters.

Now:
- **Endpoint URL** comes first and is still required.
- **Node Name (Optional)** comes next and fills in as you type the
endpoint.
- The name's help reads "Pulse shows this name across its monitoring
views. If you leave it empty, Pulse names it after the host." That holds
for manual, password and assisted setup alike.

## Verification

- **Tests:**
- `NodeCredentialSlot.test.tsx`: endpoint before name, endpoint
required, name not required, no uniqueness claim.
- Typing an endpoint fills the name, and a name the user typed is kept
when the endpoint changes.
  - `settingsArchitecture.test.ts` rejects "must be unique".
  - Both fail against `main`.
- **Suites:** Settings plus `frontendResourceTypeBoundaries` and
`nodeModalPresentation`, 2463 tests, all pass.
- Three unrelated Settings tests timed out once while pulse-dev ran at
load 14, then passed when rerun.
- **Static checks:** tsc, formatting, eslint, and the copy,
canonical-platform and form-label audits are clean. The bundle check is
clean.
- **Live, private mock stack:**
- Add Proxmox VE: typing `https://pve-lab.local:8006` filled the name
with `pve-lab.local`.
- At 1024px the two fields sit side by side, endpoint on the left. At
375px they stack, endpoint first, with no sideways scroll.
- **Review:** an independent review flagged that the first wording
("uses the host name from the Endpoint URL") was false for assisted
setup, which registers the host's reported name. The copy was changed to
"names it after the host".
- **Receipt:** the browser receipt is included.
2026-10-15 20:37:52 +01:00
rcourtman 29b9ecc9d1 Ask for the node endpoint before its name
The Add Proxmox VE/PBS/PMG form opened on Node Name, marked required
with a red asterisk, and its help said "Required and must be unique. We
can auto-fill it from the Endpoint URL if you leave it blank." Neither
half held: typing the endpoint already fills an empty name, every save
falls back to a name derived from the endpoint (assisted setup to the
host's reported name), and the server suffixes duplicate names with the
host IP. A new user met a contradictory required field before the one
input that actually matters.

Endpoint URL now comes first. Node Name follows as an optional label
that fills in as the endpoint is typed, and its help says only that an
empty name is named after the host, which is true for manual, password
and assisted setup alike.
2026-10-15 20:12:26 +01:00
rcourtman 159803974f Clear the stale reader marker between PTY test phases (#2978)
## Why

`agentInstallCommand.test.ts > terminal-to-runtime token-file
preservation` failed on #2976 (a setup-wizard change) with "Bootstrap
prompted for the token with terminal echo enabled". The failure came
from the test harness, not the bootstrap.

The test runs two or three install phases against one fixture directory.
Its PTY runner types the token when two things are true: the output ends
with the read prompt, and the fake `stty` has written `reader-pid`,
which it does on the bootstrap's `stty -echo`. The marker from phase 0
was never removed. In later phases, if the terminal's echo of the typed
command happened to end a read at the prompt text quoted inside that
command, the runner typed the token before the bootstrap had even
started, while terminal echo was still on.

## Change

The runner removes `reader-pid` before it starts each run. Only this
run's `stty -echo` can now signal the prompt.

## Verification (pulse-dev)

- **Probe:** the runner was instrumented to record, at the moment it
types the token, whether `reader-pid` is older than the current run.
Reads were made one byte at a time so every chunk boundary occurs.
- On `main`, 8 of 14 sends were early: every phase after the first,
across both uids.
  - With this change, 0 of 14 were early.
- **Full file:** `agentInstallCommand.test.ts` passed 63/63 three times
in a row.
- **Lint:** prettier and eslint are clean.
2026-10-15 19:59:29 +01:00
rcourtman a27f4ec402 Stop setup reporting a false usage-statistics failure (#2976)
## What changes for users

First-run setup has a **Usage statistics** switch. If you turn it off on
a server started with `PULSE_TELEMETRY=false`, which is the usual choice
for privacy-minded Docker users, setup said:

> Your admin account was created, but usage statistics could not be
turned off. You can turn them off in Settings → System → General.

That was false. Statistics were already off: the server refuses the
write only because `PULSE_TELEMETRY` locks the setting. Settings can't
change a locked value either.

After a refused opt-out, setup now checks what the server actually
keeps:

| Server | Message after opting out |
| --- | --- |
| `PULSE_TELEMETRY=false` | None. Statistics are off. |
| `PULSE_TELEMETRY=true` | "Usage statistics stay on because this server
sets PULSE_TELEMETRY=true. Set PULSE_TELEMETRY=false and restart Pulse
to turn them off." |
| No lock, or the state can't be read | The existing message, unchanged
|

The `true` case says to set `false` rather than remove the variable,
because removing it restores the default, which is on.

## How

- **`SecurityStep.tsx`:** when the opt-out write fails,
`readTelemetryOutcome()` reads `GET /api/system/settings`
(`telemetryEnabled` and `envOverrides`) and picks the message.
- **Catalog:** new key `setup.security.error.telemetryLockedOn` in
English, German and Spanish. The variable name stays untranslated.

## Verification

- **Tests:**
- `SecurityStep.test.tsx` covers the locked-off case (no message) and
the locked-on case (the new message), and the existing failure test
still passes. Its mock is now reset between tests so queued responses
can't leak.
  - `i18n.test.ts` requires `PULSE_TELEMETRY=false` in every locale.
  - The three new tests fail against `main`.
- The i18n, setup wizard and settings localization suites pass (151
tests).
- tsc, prettier and the copy, canonical-platform and form-label audits
are clean.
- **Live:** a fresh instance with `PULSE_TELEMETRY=false`, run through
setup with statistics switched off at 1280px and at 375px. The settings
write returned 409, the settings read returned 200, no message appeared,
and setup continued to "Connect your first system". The phone layout had
no sideways scroll.
- **Receipt:** the browser receipt is included.
2026-10-15 19:52:36 +01:00
rcourtman 7b0f51b6e9 Clear the stale reader marker between PTY test phases
The terminal-to-runtime preservation test runs several install phases
against one fixture directory. Its PTY runner sends the token when the
output ends with the read prompt and the fake stty has written
reader-pid, which it does on the bootstrap's stty -echo. The marker from
phase 0 survived into later phases, so when the terminal's echo of the
typed command happened to end a read at the prompt text quoted inside
it, the runner typed the token before the bootstrap had started, while
echo was still on. CI then failed with "Bootstrap prompted for the token
with terminal echo enabled" on an unrelated pull request (2976).

The runner now removes the marker before each run, so only this run's
stty -echo can signal the prompt.
2026-10-15 19:43:12 +01:00
rcourtman 1bd5e36afd Read canonical backup inventory in investigations (#2977)
Backup investigations could report an empty inventory for canonical
resource IDs even when the recovery API had collected artifacts. The
inventory now queries the same org-scoped recovery source and returns
canonical artifact records with repository references, verification
state and provenance. Pagination follows one global ordering across
providers. Read failures remain errors.

The obsolete PBS/PVE summary lists and unrelated recent-task cache are
removed from this tool read. Regression coverage checks mapped PVE/PBS
artifacts, duplicate VM IDs across clusters, tenant isolation, unknown
verification, pagination beyond 4,000 records, unavailable reads and
HTTP/tool parity in real and demo modes. The original regression fails
against the old implementation and passes after repair. Tools tests,
focused API tests, frontend type-check/build and private runtime builds
passed on the validation worker.

Browser verification exercised the saved investigation and expanded
failure/artifact tool evidence on /patrol at 1440x900 and 390x844,
including keyboard close/reopen and reload. This was a read-only view of
synthetic investigation records, not action or release qualification.
2026-10-15 17:43:47 +01:00
rcourtman 5866248ff4 fix(storage): read canonical backup inventory
Canonical resource queries could return no backups because inventory compared
raw guest IDs from provider caches. Read org-scoped recovery artifacts with
shared identity, repository, verification and provenance fields instead.

Preserve global pagination across providers and surface read failures. Remove
the obsolete vendor summaries and unrelated recent-task cache from this read.
2026-10-15 17:16:32 +01:00
rcourtman 8cb5b0cf22 Stop setup reporting a false usage-statistics failure
First-run setup offers a Usage statistics switch and, once the admin
account exists, writes the opt-out. A server started with
PULSE_TELEMETRY refuses that write (409 env_locked), and setup then
said "usage statistics could not be turned off. You can turn them off
in Settings" even when PULSE_TELEMETRY=false already keeps them off, so
the privacy-minded operator who set the variable was told Pulse was
still sending data. Settings cannot change a locked value either.

After a refused opt-out the step now reads the system settings: no
message when statistics are already off; when the server keeps them on
with PULSE_TELEMETRY=true, a message saying to set PULSE_TELEMETRY=false
and restart (removing the variable would restore the default, on); and
the old message only when the state cannot be read.
2026-10-15 17:08:31 +01:00
rcourtman 3043244d74 Link alert notifications to the alert in Pulse (#2975)
## What changes for users

When an alert notification arrives, its link now opens that alert in
Pulse. This needs a public URL configured, which notifications already
use.

| Notification | Before | After |
| --- | --- | --- |
| Single alert email | No link | **View this alert in Pulse** opens
Alert History narrowed to the alert, timeline open (#2974) |
| Grouped alert email | No link | Each alert's name links to it. The
footer opens the Alerts overview. |
| Telegram, Slack, Teams, PagerDuty, Teams Adaptive Card, Gotify, ntfy,
Mattermost | "View in Pulse" (several said "View in Proxmox") opened
Pulse's front page | **View in Pulse** opens the alert. A grouped
message opens the Alerts page. |
| Apprise, resolved notifications | A "Dashboard:" line | A **View in
Pulse** line per alert, beside the Dashboard line |
| Patrol finding notifications | (as above) | Open Patrol, including a
grouped batch of findings. Findings are not stored in Alert History. |

- **Custom templates** keep working unchanged: `{{.Instance}}` still
means Pulse's public URL, and the new `{{.AlertURL}}` is available to
use.
- **Without a public URL,** notifications read as before.

## How

- **`internal/notifications/alert_links.go`** holds the link logic:
- `alertHistoryURL` builds the same `/alerts/history?alert=<id>` address
as the frontend's `buildAlertHistoryAlertPath`.
  - `alertLinkURL` sends Patrol findings to `/patrol` instead.
- `alertsPageURL` links one alert to its page, a batch made only of
Patrol findings to Patrol, and any other batch to the Alerts overview
(firing) or Alert History (resolved).
- **`WebhookPayloadData.AlertURL`** falls back to `Instance` when no
public URL is set. The eight built-in templates link it, and default
JSON payloads add `alertUrl`.
- **`EmailTemplate`** gains a `publicURL` parameter, passed by both send
paths.
- **`docs/WEBHOOKS.md`** (and its shipped mirror) documents
`{{.AlertURL}}`.

Depends on #2974 for the landing page. Merged before it, the links would
open the unfiltered History page.

## Verification

- **Tests:** `alert_links_test.go` covers:
  - the address matching the frontend's encoding;
  - grouped and resolved page links;
- single and grouped email HTML and text, with and without a public URL;
  - webhook `AlertURL` and its fallback;
  - every built-in template that links Pulse rendering the alert link;
  - Apprise and resolved text;
- Patrol findings, alone or grouped, linking to Patrol, and a mixed
batch linking to the overview. Disabling either Patrol branch makes this
test fail.
- **Other checks:** the whole `internal/notifications` package passes,
and gofmt, go vet and the docs mirror check are clean.
- **Landing page:** the page these links open was checked live in #2974.
2026-10-15 17:02:03 +01:00
rcourtman 0e5b335b23 Restore collected backup and storage evidence in investigations (#2973)
Guest investigations could miss collected backup failures, and canonical
storage IDs could not retrieve metric history. Investigations now read
backup attempts through the same org-scoped recovery source as the HTTP
API and storage history through the monitor-owned source used by charts.
Responses preserve error text, timestamps, requested windows, read
failures and synthetic provenance. Dense history sampling also includes
the newest remainder observations, which the previous capped fixed-size
buckets discarded.

Regression coverage checks guest tool projection, persisted backup
attempts, resource and tenant isolation, demo-mode transitions, retained
storage history after restart, measured zero, newest observations in
dense series and API/tool coherence. Focused backend tests and both
private runtime builds passed on the validation worker. Frontend
type-check and build passed.

The separate backup inventory overview still uses legacy identifiers.
That remaining canonicalization work is recorded in the existing L6
architecture follow-up.

Browser verification exercised saved investigation summaries and
expanded tool evidence on `/patrol` at 1440×900 and 390×844, including
keyboard open/close, reopen and reload. This was a read-only view of
synthetic investigation records, not action or release qualification.
2026-10-15 16:47:34 +01:00
rcourtman c40051c809 Link alert notifications to the alert in Pulse
Alert emails had no link into Pulse at all, and the built-in webhook
templates' "View in Pulse" (several labelled "View in Proxmox") opened
Pulse's front page, so whoever followed a notification had to find the
alert again by hand.

With a public URL configured, notifications now link to the alert: one
alert opens Alert History narrowed to it (/alerts/history?alert=<id>,
the landing added in the previous change), a grouped notification opens
the Alerts overview while firing and Alert History once resolved, and a
Patrol finding (a synthetic alert History never stores), alone or in a batch of
findings, opens Patrol.
Single emails end with "View this alert in Pulse", grouped emails link
each alert, Apprise and resolved text add a per-alert link, the eight
built-in webhook templates link {{.AlertURL}} and say "View in Pulse",
and default JSON payloads add alertUrl. {{.Instance}} keeps its meaning,
so custom templates are unchanged, and without a public URL everything
reads as before.
2026-10-15 16:22:00 +01:00
rcourtman 5c9b4c8904 Open one alert in Alert History from a link (#2974)
## What changes for users

Pulse has had no address for a single alert. A notification can only
link to Pulse's front page, and you then have to find the alert again
yourself. This PR adds that address; a follow-up makes emails and
webhooks link to it.

`/alerts/history?alert=<id>` opens **Alerts › History** narrowed to that
alert:

- **One alert.** History shows only that alert's occurrences, with the
latest one's timeline already open. If the alert is still firing, that
occurrence comes first.
- **Named.** A notice says what you're looking at: "Showing Container
Health on nginx, opened from a link." **Show all alerts** returns to the
full list.
- **Older alerts.** If the alert is older than the period on show,
History widens to All time once. Any later period you choose is
respected.
- **Gone alerts.** If history no longer holds the alert, it says so:
"That alert is no longer in Pulse's alert history."
- **Live alerts unavailable.** If Pulse can't load the alerts that are
firing now, it doesn't guess. It says the alert may still be active and
offers **Retry**.
- **Phones.** On a phone the timeline opens in the card drawer.

## How

- **`buildAlertHistoryAlertPath`** and **`ALERT_HISTORY_QUERY_PARAMS`**
now live in `routing/resourceLinks.ts`. The `alert` key is a public
address, because notifications will build the same link.
- **`useAlertHistoryState`** handles the narrowing and decisions:
- It narrows to the id before the severity and search filters, and shows
every loaded occurrence regardless of the range window. A still-firing
alert can predate the range.
- It waits until saved history has loaded **and** the live alert list is
`ready`, because saved history holds resolved alerts only.
- It decides once per alert whether to widen the range and whether the
alert is missing.
- It opens the latest occurrence through the shared timeline expansion
and publishes a one-shot drawer request for the cards, so a remounted
list can't replay it.
- **`stores/websocket.ts`**: `commitActiveAlertsTruth` publishes `ready`
and the alerts in one `batch`. Previously the REST recovery path set
`ready` first, so a reader could briefly see "ready" with an empty list.

## Verification

- **Tests:**
- `useAlertHistoryState.test.tsx` covers narrowing and opening; widening
once; waiting for live alerts; a firing alert older than the range; the
operator's later range; the one-shot drawer request; and the missing
case.
  - `HistoryTab.test.tsx` covers the notice and **Show all alerts**.
- `websocket-resilience.test.ts` checks that readiness is published with
the recovered alerts. It fails on main's store.
`websocket-unified.test.ts` checks the same over a socket snapshot.
- `resourceLinks.test.ts` and `alertOverviewPresentation.test.ts` cover
the link builder and the notice text.
  - Ten of these fail against main's code.
- **Suites:** the routing, utils, alerts, Alerts components, pages and
stores suites pass (8,330 tests). tsc, prettier, the copy,
canonical-platform and form-label audits, and the bundle check are
clean.
- **Live, mock estate:**
- An active alert's link stays in the 7-day range, shows one row with
its timeline open, and works with ids containing colons, slashes and
spaces.
  - A July alert widens to All time and opens.
  - An unknown id says it's gone.
  - **Show all alerts** restores the list.
  - On a 375px phone the drawer opens, and stays closed after Escape.
- **Receipt:** the browser receipt is included.
2026-10-15 16:19:31 +01:00
rcourtman 94523d8807 fix(metrics): retain newest observations in bounded history
Fixed-size buckets followed by an output cap dropped the newest remainder
samples in dense metric histories. Divide the full input proportionally so
every observation contributes to the bounded response.

Cover newest CPU, memory and storage evidence at 121, 239, 500 and 1000
samples. The regression fails before the fix and passes after it.
2026-10-15 16:18:11 +01:00
rcourtman e14f616921 Open one alert in Alert History from a link
Alert notifications can only link to Pulse's front page: there is no
address for one alert, so whoever follows an alert email or webhook has
to find the alert again by hand. This adds the landing for that link.

/alerts/history?alert=<id> narrows History to that alert's
occurrences, opens the latest one's timeline (still firing first) and
names it in a notice with Show all alerts. Saved history holds resolved
alerts only, so the page waits for the live alert list before deciding:
an alert older than the range widens History to All time once, an id
history no longer holds is said to be gone, and while the live list is
unavailable the notice says the alert may still be firing and offers
Retry. Every loaded occurrence shows regardless of the range window,
the decisions run once per alert so later filters stay the operator's,
and the phone cards take a one-shot drawer request.

The live alert store now publishes "ready" and the alerts in one batch,
so no reader sees readiness before the alerts it vouches for.
Notifications will build this link in a follow-up.
2026-10-15 15:53:50 +01:00