Commit Graph

7302 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 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] 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
2026-10-16 20:44:47 +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 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 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
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
2026-10-16 15:13:51 +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 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 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 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 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 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 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 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 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
rcourtman 223c2d2449 fix(patrol): restore collected investigation evidence
Guest investigations could miss collected backup errors and could not read
storage history through canonical resource IDs. Share recovery point reads
with the HTTP API and monitoring history with charts, preserving tenant and
resource scope, timestamps, provenance, window bounds, and read failures.

Cover manifest projection, persisted recovery tasks, demo source parity,
retained storage history after restart, and API/tool evidence coherence.
2026-10-15 15:48:28 +01:00
rcourtman 29514b835f Title the alerts Schedule page Delivery Schedule
The Alerts > Schedule page sets how alerts are delivered: quiet hours,
cooldown, grouping, delivery channels, recovery notifications and
escalation. Its header and browser tab called it "Maintenance
Schedule", a title kept from 2025 and carried into the German and
Spanish catalogs, which promised maintenance windows. Those are a
separate per-resource feature, so an operator looking for them landed
here and found none.

The title is now "Delivery Schedule" (de "Zustellplan", es
"Programacion de entregas"); the nav label stays "Schedule". The catalog
test refuses a maintenance word in any locale's title.
2026-10-15 14:45:47 +01:00
rcourtman 898981c6d8 Show Alert History as cards when its card is too narrow (#2971)
## What changes for users

**Alerts › History** picked its table or its phone cards by screen
width: the table from 768px up. With the Alerts sidebar open, though, a
1024px to roughly 1240px window leaves the table only a 600–800px card.
At that width the table:

- stacks its three row actions on top of each other;
- breaks resource names mid-word ("inventory-/api");
- cuts messages off after two lines.

The cards are now chosen by the card's own width:

| Window | History card | Layout |
| --- | --- | --- |
| 1280px, sidebar open | 837px | table (unchanged) |
| 1024px, sidebar open | 602px | cards, full names and messages |
| 900px (sidebar becomes tabs) | 774px | cards |
| 375px phone | 341px | cards (unchanged) |

An open timeline or resource view follows you across the switch: it
moves into the cards' drawer when the window narrows, and back into the
table row when it widens.

## How

- **`AlertHistoryTableSection` measures its card.** It uses the
canonical `useObservedElementWidth` and shows the cards below 800px
(`ALERT_HISTORY_TABLE_MIN_WIDTH`), the width from which the row actions
share one line. Until the card is measured, the phone breakpoint decides
as before. The decision is a memo.
- **`AlertHistoryMobileList` takes an `active` signal** in place of its
own `matchMedia('(min-width: 768px)')`. It memoises that signal and
moves the shared expansion state into its drawer only when the layout
actually changes. Without the memo, every resized pixel re-ran that move
and could reopen a drawer the operator had closed.
- **Both layouts stay mounted, as before.** The hidden one gets
`hidden`.
- **`registry.json`.** `AlertHistoryMobileList.test.tsx` and
`AlertHistoryTableSection.test.tsx` join the alerts frontend-surface
proof files. They are the tests that prove this surface.

## Verification

- **Tests.** New tests cover:
  - the width decision (`AlertHistoryTableSection.test.tsx`);
- layout handover and the memo through a width-derived signal
(`AlertHistoryMobileList.test.tsx`). With the list's memo removed, that
test fails.

  All four new or changed tests fail against main.
- **Suites.** The alerts, Alerts components and pages suites pass (2,497
tests). tsc, prettier, the copy, canonical-platform and form-label
audits, and the bundle check are clean.
- **Live (mock estate).** The table-or-cards results match the table
above. A card timeline opens the drawer at 1024px. An inline timeline
survives 1280 → 1024 → 1280. The 375px phone has no sideways scroll.
- **E2E.** The Playwright desktop project (Desktop Chrome, 1280px) keeps
the table.
- The browser receipt is included.
2026-10-15 14:27:33 +01:00
rcourtman 90f819b309 Show Alert History as cards when its card is too narrow
Alerts > History chose between its table and its phone cards by the
viewport: the table from 768px. With the Alerts rail open, a 1024px to
roughly 1240px window gives the table a 600px to 800px card, where its
own CSS stacks the three row actions and resource names and messages
break mid-word ("inventory-/api") or end after two lines.

The table section now measures its card with useObservedElementWidth and
shows the cards whenever the card is narrower than 800px, the width from
which the row actions share one line; before the card is measured, the
phone breakpoint decides as before. The cards take that decision as an
`active` signal instead of their own 768px media query, so an open
timeline or resource view still moves into the cards' drawer when they
take over and back to the inline expansion when the table returns. The
decision is a memo so a resize within one layout does not re-run that
move and reopen a drawer the operator closed.
2026-10-15 13:59:11 +01:00
rcourtman cfcc0d0032 Keep release visuals near highlights and collapse upgrade guidance
Show optional screenshots directly after highlights and move routine upgrade
instructions into their own disclosure. Keep known issues and breaking changes
expanded before the highlights, and tell release authors to place critical
risks in the visible warning section.
2026-10-15 13:52:44 +01:00
rcourtman 6fca29ca81 Name Alert History row controls after their alert (#2968)
## What changes for users

Every **Alerts › History** row, in both the desktop table and the phone
cards, has the same three controls: **Timeline**, **Resource** and an
icon-only **Ask Pulse Assistant** button. Each of these had the same
name on every row. On the mock estate, a screen reader or voice control
saw:

- 91 buttons called "Timeline"
- 91 buttons called "Resource"
- 45 buttons called "Ask Pulse Assistant about this alert"

Now each control names the alert it belongs to:

| Control | Accessible name |
| --- | --- |
| Timeline | `Timeline for Memory Limit on inventory-api, Thursday, 15
October 2026 at 12:56:37` (`Hide timeline for …` while open, with
`aria-expanded`) |
| Resource | `Resource incidents for inventory-api` |
| Assistant | `Ask Pulse Assistant about Memory Limit on inventory-api,
Thursday, 15 October 2026 at 12:56:37` |

History repeats the same alert over time, so the start time is what
tells those rows apart. Resource names repeat only across rows on the
same resource, because those buttons open the same panel. Each name
starts with the visible word, so voice control still matches "Timeline"
or "Resource". The visible text and the hover tooltip are unchanged.

## How

- **Shared label helpers.** `getAlertHistoryTimelineActionLabel` and
`getAlertHistoryResourceActionLabel` live in
`utils/alertIncidentPresentation.ts`, as the canonical-platform audit
requires for alert history copy.
- **Row actions.** `AlertHistoryItemActions` uses those helpers and adds
`aria-expanded` to Timeline.
- **Assistant button.** The icon-only variant of
`InvestigateAlertButton` takes `subjectLabel` and `subjectTime`, and
names itself through the new catalog keys `alerts.assistant.iconLabel`
and `alerts.assistant.iconLabelAt` (English, German, Spanish). A locked
button keeps its locked explanation. Only History uses the icon variant.

## Verification

- **New tests:**
- row names, the open and closed state, and two occurrences of one alert
getting different names (`AlertHistoryItemActions.test.tsx`);
  - the phone card names (`AlertHistoryMobileList.test.tsx`);
- the Assistant names, with and without a start time, and the locked
state (`InvestigateAlertButton.test.tsx`);
  - the label helpers (`alertIncidentPresentation.test.ts`);
  - every locale keeping the placeholders (`i18n.test.ts`).

  Ten tests fail against main's components.
- **Suites:** the alerts, Alerts components, i18n and helper suites pass
(2,446 tests). tsc, prettier, and the copy, canonical-platform and
form-label audits are clean, and so is the bundle check.
- **Desktop (1280px):** live on the mock estate, the 75 rendered
Timeline and Assistant buttons have 75 distinct names.
- **Phone (375px):** the 44 card controls are all distinct, and an open
timeline reads "Hide timeline for …" with `aria-expanded="true"`.
- The browser receipt is included.
2026-10-15 13:30:27 +01:00
rcourtman fdf9a56ad3 Improve release highlights and reusable screenshot evidence (#2966)
The post-update dialog now leads with three release highlights, keeps
upgrade warnings visible, and puts grouped details behind “All changes”.
Optional before/after screenshots stack on mobile, with full-size links
and a fallback for unavailable images. Current release-authoring groups
are covered so published changes are not silently dropped.

The PR template asks for relevant screenshots using safe demo data.
Release preparation can select images from merged PRs, verify their
provenance, and publish normalized, versioned PNG assets. Fresh captures
remain available when existing evidence does not represent the final
release. Images load through the authenticated release endpoint under
the existing content security policy.

## Validation

- 36 focused frontend tests, frontend lint and type check, production
frontend and backend builds.
- Backend image validation and route authorization tests, release visual
and rendering tests, and governance audits.
- A separate filesystem-history fixture correction captures the real
cycle start before polling. A controlled slow-poll reproduction fails
the old history assertion and passes with the correction.
- Fresh capture and PR-image reuse smoke checks against the built
application.
- Browser checks on `/proxmox` at 1440 × 1000 and 390 × 844. Covered
disclosures, scrolling, before/after and current-only images,
unavailable images, historical text-only notes, keyboard focus, Escape,
backdrop and button dismissal, and reload persistence.

## Visual evidence

These views use the current built application, generated demo data and
existing release text. Controlled release transport fixtures also cover
the optional comparison states.

Desktop:

![Release highlights on
desktop](https://raw.githubusercontent.com/rcourtman/Pulse/150042fe98a1054e2d0aa397019dad891921c997/frontend-modern/browser-verification/screenshots/release-highlights-desktop.png)

Mobile:

![Release highlights on
mobile](https://raw.githubusercontent.com/rcourtman/Pulse/150042fe98a1054e2d0aa397019dad891921c997/frontend-modern/browser-verification/screenshots/release-highlights-mobile.png)
2026-10-15 13:09:12 +01:00
rcourtman b6cc0d01c9 Name Alert History row controls after their alert
Every Alerts > History row repeats Timeline, Resource and an icon-only
Assistant button, and each shared one name on every row: the mock
estate rendered 91 buttons called "Timeline", 91 called "Resource" and
45 called "Ask Pulse Assistant about this alert". A screen-reader or
speech-input user could not tell which row a control belonged to.

Timeline now names the alert, its resource and the row's start time
(history repeats one alert over time), reads "Hide timeline for ..."
while open and reports aria-expanded. Resource names its resource, and
the Assistant icon names the alert, resource and start time through new
catalog keys in English, German and Spanish. Each name starts with the
visible label so speech input still matches it; the visible text and
the hover title are unchanged. The label copy lives in the shared alert
incident presentation helpers.
2026-10-15 13:03:50 +01:00
rcourtman c509bbc314 Link the action review to the resource it acts on (#2965)
## What changes for users

The governed action review on the **Actions** page tells you to check
the actual resource before approving, retrying, or closing an action
whose outcome is unknown. Until now its only link went back to Patrol.
The host, VM or container the action would change was plain text.

The review now has **Open resource** beside **Open Patrol**:

| Action on | Opens |
| --- | --- |
| A standalone agent (e.g. install OS updates on `Apollo-114`) |
Machines, filtered to the host |
| A Proxmox VM (e.g. restart `analytics-batch-01`) | Proxmox overview,
with the VM's details expanded |
| A Docker container (e.g. restart `backup-coordinator`) | Docker
overview, filtered to the container |

Back returns you to the same open review.

This supports the position recorded for issue #1891, where a failed
container update left no next step: check the running container before
taking another action.

## How

- **`useResolvedResource`** (new,
`features/platformNavigation/useResolvedResource.ts`) holds the registry
lookup the Patrol workbench did inline. It runs one abortable lookup per
id and never returns an answer for an earlier id.
- **`ActionReviewDialog`** takes a new `showResourceLink` prop. With it,
the dialog resolves `request.resourceId` with the hook and builds the
link with `buildResourcePagePath`, falling back to the audit's recorded
resource name and type. Both links wrap in one row and are 44px tall on
phones.
- **Only the Actions page passes `showResourceLink`.** It keeps the
review in `?action=`, so Back reopens it. Patrol keeps its review in
memory and already links the decision's resource. The Docker container
controls open the review from the container itself. Those hosts get no
link and make no lookup.
- **`PatrolAttentionWorkbench`** uses the hook. Its behaviour is
unchanged.
- **`.bundlesize.json`**: the page mapping now lives in the
`ActionReviewDialog` chunk that Actions and Patrol share. That chunk's
baseline records its measured size (8453 → 10414 bytes gzipped). The
Patrol chunk shrank by about 1.1 kB, so the net growth is about 0.8 kB.

## Verification

- **Tests.** New `ActionReviewDialog` tests cover the registry-resolved
link, the fallback link and the default-off case. A new Actions page
test checks the link appears there. The three link tests fail against
main's code. `useResolvedResource.test.ts` covers the stale-answer
guard.
- **Suites.** The actions, Patrol, platform-navigation, Docker and
shared-component suites pass (2,474 tests). One `DockerHostsTable` test
timed out under full-suite load on pulse-dev, but it passes in isolation
on both this branch and the base. tsc, prettier, the copy,
canonical-platform and form-label audits, and the bundle check are
clean.
- **Actions.** Live on the mock estate, all three demo actions link to
their resource's page, each page shows the resource, and Back returns to
the review.
- **Patrol.** Patrol's own links are unchanged.
- **Phone.** At 375px the two links sit on one row with no sideways
scroll.
- The browser receipt is included.
2026-10-15 12:32:21 +01:00
rcourtman 590355bcab Label the alert frequency axis with round local times
The alert history chart's default 7-day view labelled its axis with a
date and an hour only, which 24-hour locales print as "8 Oct, 11": a
date followed by a bare number. The ticks also sat on raw bucket edges,
and buckets start at now minus the range, so adding minutes alone would
have printed times like "10 Oct, 05:50", and a 30-day tick dated "1 Oct"
actually marked 1 Oct 11:50.

Hour labels now carry their minutes, and each inner tick moves to the
next round local time (the next whole bucket step in the day, or local
midnight for date-only labels) at that time's true position. The first
tick keeps the exact window start the range line shows, the last still
reads Now, and the buckets, tooltips and counts are unchanged. An inner
tick that would land on or right next to the previous one is dropped,
which keeps a repeated hour at a clock change from stacking two ticks.
2026-10-15 12:01:21 +01:00
rcourtman 0e3e07a9d4 Merge current main into release highlights
Keep release screenshots alongside the current update API changes.
Retain both screenshot and Pro runtime capabilities in the update test helper.
2026-10-15 11:50:26 +01:00
rcourtman 150042fe98 Improve release highlights and reusable screenshot evidence
Put published highlights and optional screenshot comparisons in the
post-update dialog, with grouped details available on demand.

Reuse inspected images from merged pull requests or capture fresh release
views, then publish versioned PNG sidecars. Serve screenshots through the
authenticated release endpoint to preserve the existing image policy.
2026-10-15 11:42:25 +01:00
rcourtman 9fbff10da5 Link the action review to the resource it acts on
The governed action review tells the operator to check the actual
resource before approving an action, retrying it, or closing one whose
outcome is unknown, but its only link went back to Patrol. The host,
VM or container the action would change was plain text, so checking it
meant leaving the review and finding it by hand.

On the Actions page the review now shows Open resource beside Open
Patrol, built with the same page mapping as Patrol's Open resource: the
registry resource's platform decides the page, and the audit's recorded
name and type stand in while the lookup runs or when the id no longer
resolves. The link is opt-in (showResourceLink). The Actions page keeps
the review in ?action=, so Back from the resource reopens it. Patrol
holds its review in memory and already links the subject, and the Docker
container controls open the review from the resource itself, so those
hosts get no link.

The registry lookup the Patrol workbench did inline moves into a
shared useResolvedResource hook, so both surfaces use one abortable,
id-keyed lookup. The page mapping now lives in the chunk the Actions
and Patrol pages share, so that chunk's bundle baseline records its
measured size; the Patrol chunk shrank by about the same amount.
2026-10-15 11:26:54 +01:00
rcourtman 482373a875 Open Patrol's thresholds link on the resource's own row
A Patrol decision about a bare alert tells the operator to adjust when
the alert fires or turn it off under alert thresholds, but its link
opened /alerts/thresholds, the top of the Proxmox tab, whatever the
resource was. The operator had to find the platform tab and search for
the resource themselves.

The link now opens the resource's platform tab with the thresholds
search set to the display label its row is built from. Physical disks
and composed ZFS labels open the tab unsearched, because a search that
matches nothing would hide every row.

To make that possible the thresholds search is route state (?q=),
kept across tab clicks, section hashes and legacy-path redirects. Host
and Docker host rows also match their raw name, which holds the full
label while the row shows a shortened one. Because each search edit
now navigates within the page, the Alerts unsaved-changes guard asks
only when the destination path changes, so narrowing the list no
longer prompts while leaving the page still does.
2026-10-15 10:49:42 +01:00
rcourtman cca418cc8a Open a Patrol decision's resource on the page that shows it (#2962)
## What changes for users

In **Patrol**, a decision's **Open resource** button now goes to the
page that shows that resource. Before, it always went to **Machines ›
Machines**, which lists only standalone agent hosts and ignores the
`?resource=` it was given. On the mock estate, 19 of 20 decisions opened
a page that didn't show what they were about: Docker containers and
hosts, Proxmox VMs, LXCs, storage and disks, vSphere hosts, networks and
datastores, TrueNAS datasets, and Mail Gateway.

Each decision now opens the page for its platform, selecting or
filtering to the resource where that page can:

| Subject | Opens |
| --- | --- |
| Proxmox VM / LXC | Proxmox overview, with the guest selected |
| Proxmox storage, ZFS pool or device | Proxmox › Storage, with the pool
selected |
| Proxmox physical disk | Proxmox › Storage › Disks, filtered to the
disk |
| Docker container | Docker overview, filtered by name (Swarm, image,
volume and network kinds open their tabs) |
| Kubernetes pod, workload, node, service and so on | The owning
Kubernetes tab, filtered by name |
| vSphere VM | vSphere overview, with the VM selected |
| vSphere host / network / datastore | vSphere overview / Networks /
Storage |
| TrueNAS dataset, pool or disk / app / VM / share | TrueNAS Storage /
Apps / VMs / Shares |
| PBS / PMG / Ceph | Proxmox Backups / Mail Gateway / Ceph |
| Standalone agent host / availability check | Machines (filtered by
name) / Availability checks |

## How

- **`buildResourcePagePath`** (new, in
`features/platformNavigation/resourcePagePath.ts`):
- **Platform:** chosen from the resource's own platform evidence
(`collectResourcePlatformEvidence`). When the registry can't resolve the
record, the type hint decides instead, including the alert display words
such as "vSphere VM" and "TrueNAS Dataset".
- **Destination:** the resource is selected where the page supports it,
filtered by name where that's the page's only option, and otherwise the
owning tab opens.
- **Patrol workbench:** it resolves `subjectResourceId` through
`ResourceAPI.getResource`, with one abortable request per item, and
hides the link if no page can show the subject.

## Verification

- `resourcePagePath.test.ts` covers registry-resolved and hint-only
cases, including TrueNAS apps, Docker Swarm and Kubernetes kinds,
display-word hints, ZFS composite ids and availability checks.
`PatrolAttentionWorkbench.test.tsx` covers the fallback and registry
paths; both of its new cases fail against main's code.
- The Patrol, platform-navigation and routing suites pass (456 tests),
and tsc, prettier and the copy and canonical-platform audits are clean.
- Live on a mock estate, every Patrol decision opened a page that shows
its resource. A ZFS device decision opened its pool, and on a 375px
phone a VM decision opened the VM. The receipt is included.
2026-10-15 10:47:21 +01:00