diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index fefabf9828..20fd82c066 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -3236,3 +3236,25 @@ The monitor delegates recovery eligibility to persisted occurrence/destination
receipts even when an old retry remains or restart has lost RAM cooldown state.
The partial-destination monitor control establishes a real local HTTP 200/503
split and preserves the newer firing and the old recipient's recovery.
+
+### Notification activity reports each evidence source independently
+
+The existing Notifications activity card distinguishes an initial or pending
+read from a confirmed empty window. Held/deferred event reads have their own
+loading and unavailable state; a failed read withdraws their prior rows and
+keeps the warning until a later successful read. Only the current refresh owns
+those states, and disposed readers cannot publish late completions. A slow
+held-event read does not delay delivery attempts or disable their refresh.
+
+A delivery-attempt failure withdraws only attempt rows, not independently
+readable held events. Conversely, missing held evidence does not hide readable
+attempts or imply that no notifications were held. The combined empty state
+requires both sources to be readable and no refresh pending. Retention, event
+limits, queue actions, permissions and notification delivery policy are unchanged.
+
+`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx`
+and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery,
+initial loading and current-request ownership; the production Notifications
+tab browser fixture verifies the connected read/retry states at desktop and
+phone widths. These are synthetic frontend controls, not recipient delivery
+or containing-release acceptance.
diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md
index 932168a995..fa92cf9255 100644
--- a/docs/release-control/v6/internal/subsystems/deployment-installability.md
+++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md
@@ -15,6 +15,23 @@
## Purpose
+### Update History consent does not infer the backup's restore scope
+
+The confirmation names the observed running server separately from the selected
+entry's pre-/post-update versions. No version threshold or free-form notes stand
+in for actual scope: the current History API does not describe the manifest or
+active-store layout. Explain that installation-only recovery leaves active stores
+in place but may restore independent installation files; legacy recovery can
+also replace install-local configuration/data. Do not promise a settings/alerts
+rewind. Full-state recovery still requires a stopped service and a consistent
+backup of every active store, via the existing shipped recovery instructions.
+
+Opening/cancelling consent sends no rollback request, pending consent rejects
+duplicate actions and cancellation, and a rejected request retains the warning
+and refreshes history. Mounted UpdateHistory tests and the production-CSS offline
+browser fixture exercise those states; neither proves a binary/data rollback.
+
+
### Demo transactions share the service host, not just a workflow queue
Stable demo update and recovery keep their existing publication/activation,
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 7f1fbe6186..698506168e 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,36 @@
## Purpose
+### Composed operator tables and conservative rollback consent
+
+Phone storage tables use the canonical weighted-width helper and matching
+header/cell gutters. Compact labels shorten disk verdicts and capacity values,
+not their underlying health or measured units; full reasons stay in the real
+disk header and tooltips. Structured ZFS scan activity supplies a compact badge
+only during reported rebuild activity; its complete provider summary remains
+available. Stable disk History catalog/organisation/access ownership is unchanged.
+
+Controller expansions carry the supplied kind-specific targets, absolute times,
+duration and cluster/namespace when the phone row omits those columns. Proxmox
+coverage distinguishes independent backups from guest-local snapshots; By date
+retains archive identifiers in hover/search while showing the format as detail.
+Phone identity weights leave every configured metric column visible.
+
+UpdateHistory consent names both the requested target and the observed running
+version, separately from the version on the selected history entry. The API does
+not expose backup restore scope: do not infer it from a version cutoff, notes or
+a retained path. Explain installation-only versus legacy recovery and stopped,
+consistent full-state recovery without promising later settings will be reverted
+or preserved. Opening/cancelling never starts a write, rejection retains consent,
+and a pending request cannot be submitted again or cancelled.
+
+Verification: the mounted UpdateHistory regression tests, existing kind-specific
+controller/storage/backup/workload tests and production-CSS browser fixture
+`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed
+source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
+not establish native collectors, tenancy, installation recovery or release delivery.
+
+
### Organisation-scoped single-metric History
Fetched HistoryChart observations belong to the active organisation as well as
@@ -775,6 +805,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths.
## Shared Boundaries
+### Transport-independent operator rows
+
+Proxmox/Storage and Docker presenters retain the shared table/detail, responsive
+column weighting, relative-time and governed action primitives. Display names
+for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS,
+SMB/CIFS and the other supported acronyms); only explicitly shared storage on a
+synthetic cluster/shared location gets the shared label. That label does not
+alter canonical identity, parent, History target or pool-to-disk ownership.
+
+The Docker image phone projection pairs hidden headers and cells and gives
+measured size and update verdict room without hiding identity or dropping the
+existing detail disclosure. The container projection retains its shared 30%
+phone identity reservation and five scan signals; off-row restart/host/consumer
+evidence remains in detail. Swarm rollout tokens read as words, with compact
+Running headings and wrapping rollout states on phones; raw rollout and
+placement evidence remain reachable through keyboard/touch row disclosure.
+`platformOverviewLayout.guardrails.test.ts`, mounted
+Docker table tests and the desktop/touch-phone production-component browser
+fixture check these priorities, rather than treating an old percentage as an
+immutable primitive contract. No new public route or navigation entry is added.
+
+
### Maintenance schedule copy
Operator maintenance banners show local absolute start and end timestamps,
@@ -7856,3 +7908,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain
cover the gating, the reduced form, the pinned fields, and the saved payload.
This is a presentation and form-state boundary; the schedule API contract,
report rendering, and email delivery are unchanged.
+
+### Notifications activity has independent evidence-read states
+
+The Notifications tab composes the delivery-log card with both attempt-read
+and held/deferred-event loading and unavailable state from its owned reader.
+Pending or failed evidence is never rendered as a confirmed empty activity
+window. Each failed source withdraws its own rows without hiding independently
+readable rows from the other source. Source-specific warnings have `role="alert"`
+and loading text has `role="status"`; a slow held-event read does not block the
+existing attempt-refresh button. Recovery clears a warning only after a
+successful read, without remounting the destination editors or changing their
+unfinished input.
+
+The connected Notifications browser fixture uses the real tab/state/API/card
+chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS,
+403/503 controls and pending recovery. Unit controls separately cover current
+request ownership and disposal. No mutation, route, navigation entry, queue
+policy, permission or recipient-delivery contract is added by these states.
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 80cd898e5a..ecccb3fe0a 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -15,6 +15,16 @@
## Purpose
+### Phone workload identity weights
+
+The existing phone guest projection allocates 38 percent to the name cell,
+which includes disclosure, status and backup controls, while normalising every
+remaining visible metric weight. Narrow/mobile/desktop modes and windowing
+ownership remain unchanged. Guest column regressions verify the sums and the
+production composed-table browser fixture checks identity and all configured
+metric tracks at narrow width; this is readability, not a throughput claim.
+
+
### Bounded confidence/priority work in broadcast grouping — issue #2199
The general identity matcher retains every confidence, reason, ordering and
@@ -301,6 +311,21 @@ the existing bounded HTTP client and artifact-size checks.
## Shared Boundaries
+### Multi-disk Bars summary
+
+The existing vertical-bars workload/node disk cell shows the fullest measured
+disk's percentage beside equal-weight per-disk micro-bars. Its title retains
+the actual mount and percentage. Unknown/invalid usage cannot produce a new
+summary; an aggregate fallback must not masquerade as measured member usage.
+The displayed maximum uses the existing shared disk measurement and threshold
+logic; no collector, sorting key, metric units, History request, resampling or
+performance threshold changes. `StackedDiskBar.test.tsx` and the model branch
+controls cover real/unknown measurements and unchanged per-member bars.
+`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title
+at desktop and phone widths; this is presentation proof, not native collection
+or a measured fleet performance improvement.
+
+
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index c0a85bd7b9..ef5654ebc6 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -21,6 +21,28 @@
## Purpose
+### Backup identity, phone storage and History consent composition
+
+The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
+backup files, never guest-local snapshots. Guest snapshots remain listed as
+restore evidence but cannot make an old independent backup look current.
+Archive detail uses its format (or filename fallback), preserving the full
+provider volid for hover and search in both Coverage and By date.
+
+Phone pool/disk rows preserve health and identity while using weighted widths,
+consistent gutters and compact capacity/health labels. Full disk risk reasons
+move into the existing expanded header; the ZFS activity badge uses supplied
+scan data only when rebuild is reported and keeps the full summary as its title.
+Collection-independent, organisation-owned disk History remains intact.
+
+The UpdateHistory confirmation is a version-rollback consent, not a full-state
+recovery promise. Its current API exposes no verified restore-scope metadata;
+copy must retain installation-only and legacy differences and direct full-state
+recovery to the stopped-service procedure. Regressions cover these distinctions,
+archive/snapshot attribution and storage values; the composed browser fixture
+checks real table/drawer rendering, not native or installed recovery.
+
+
### Disk History is independent of current collection
The existing physical-disk History catalog is selected by disk family, not by
@@ -378,6 +400,20 @@ command-capable profile.
## Shared Boundaries
+### Operator storage vocabulary without identity changes
+
+Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin,
+NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win.
+Only a storage explicitly marked shared whose retained location is the synthetic
+cluster/shared marker reads Shared, with the supplied reporting-node count when
+more than one node is present. Node-local storage and shared storage with a
+real native location keep that location. This is host-label presentation, not
+resource consolidation: IDs, parent/node hints, ownership evidence, metrics
+targets, alerts, health and recovery authority are unchanged. Adapter/presentation
+regressions and the production storage table browser fixture verify labels
+without treating shared capacity as a backup or demonstrating native recovery.
+
+
- After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery.
### PBS host history correlation
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 975c62f408..c93d87e6c8 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -23,6 +23,23 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
+### Narrow controller and backup summaries retain their source facts
+
+Kubernetes controller row expansions expose supplied kind-specific targets,
+counts, absolute timestamps, valid elapsed durations and namespace/cluster when
+responsive rows hide those columns. Missing facts are omitted and the ordinary
+pluralisation model handles singular targets. Job/CronJob timestamp regressions
+and the production composed-table browser fixture verify this existing summary.
+
+Proxmox Coverage uses latestBackup for completed independent backup evidence,
+with guest-local snapshots retained separately. Responsive backup-server, host
+and guest rows allocate space through existing canonical weighted-width helpers;
+archive format is presentation only, with the provider identifier retained in
+hover and search. The recovery-model, table and guest regressions plus desktop/
+phone browser rendering verify attribution, visible facts and source composition.
+These frontend improvements change no collector, API authority or native status.
+
+
### Resource evidence fallback access boundaries
When the existing resource-facet reader receives a final HTTP 401/403, its
@@ -600,6 +617,29 @@ about the same disk cannot diverge.
## Shared Boundaries
+### REST facets and container table projection
+
+The unified-resource REST mapper carries PMG metadata, per-source status,
+server action-readiness refusals and technology at the same top-level paths
+used by canonical websocket rows. Existing platformData compatibility paths
+remain; transport order must not decide whether mail counters or lifecycle
+refusal explanations are present. A replaced REST snapshot clears removed
+facets instead of retaining old counters or refusal reasons. Mapping does not
+invent capabilities or bypass planning, review, server authorisation or execution.
+`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale,
+failed and server-denied observations alongside a no-invented-grant control.
+
+The Docker phone container projection keeps identity, state when applicable,
+CPU, memory and update; restart counts remain in the existing detail expansion
+and return at mobile width. Images retain image, size and update verdict on
+phones; host and consumers stay reachable in detail. Hidden header/body columns
+are symmetric. Swarm rollout tokens render as words with raw state retained in
+the title; task starts use the shared relative-time primitive with their exact
+timestamp retained. Native table/model/guardrail tests and the production
+`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
+a container action or claiming native appliance or release acceptance.
+
+
### PBS host history correlation
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
diff --git a/frontend-modern/browser-tests/notification-evidence.cjs b/frontend-modern/browser-tests/notification-evidence.cjs
new file mode 100644
index 0000000000..8a6fef7319
--- /dev/null
+++ b/frontend-modern/browser-tests/notification-evidence.cjs
@@ -0,0 +1,383 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+const output = '/workspace/tmp/notification-evidence-proof';
+const root = '/workspace/frontend-modern';
+const runtime = [
+ 'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx',
+ 'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts',
+ 'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts',
+ 'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx',
+ 'frontend-modern/src/utils/alertDestinationsPresentation.ts',
+];
+const log = (entries = []) => ({
+ entries,
+ window_days: 30,
+ completed_retention_days: 7,
+ dead_letter_retention_days: 30,
+});
+const sent = {
+ notificationId: 'synthetic-attempt',
+ type: 'email',
+ outcome: 'sent',
+ alertIds: ['synthetic-disk-alert'],
+ alertCount: 1,
+ attempts: 1,
+ success: true,
+ timestamp: '2026-10-03T10:00:00Z',
+};
+const held = [
+ {
+ id: 42,
+ type: 'notification_deferred',
+ alertId: 'synthetic-held-alert',
+ resourceName: 'synthetic-nas',
+ alertType: 'usage',
+ reason: 'quiet_hours:performance',
+ occurredAt: '2026-10-03T10:01:00Z',
+ },
+];
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const playwrightVersion = require('playwright/package.json').version;
+ assert.equal(
+ playwrightVersion,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const hashes = Object.fromEntries(
+ runtime.map((p) => [
+ p,
+ crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.join('/workspace', p)))
+ .digest('hex'),
+ ]),
+ );
+ process.chdir(root);
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5291, strictPort: true },
+ });
+ const results = [],
+ screens = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark] of [
+ ['desktop', chromium, 1365, false],
+ ['phone', webkit, 390, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ isMobile: width === 390,
+ hasTouch: width === 390,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ page.setDefaultTimeout(20000);
+ const errors = [],
+ offOrigin = [],
+ writes = [],
+ requests = [],
+ checks = [],
+ releases = [];
+ let attemptMode = 'hold-empty',
+ heldMode = 'hold-empty';
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.route('**/*', async (route) => {
+ const request = route.request(),
+ url = new URL(request.url());
+ if (url.origin !== 'http://127.0.0.1:5291') {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (request.method() !== 'GET') {
+ writes.push({ method: request.method(), path: url.pathname });
+ return route.abort();
+ }
+ if (
+ url.pathname === '/api/notifications/delivery-log' ||
+ url.pathname === '/api/alerts/events'
+ ) {
+ const isHeld = url.pathname === '/api/alerts/events';
+ const mode = isHeld ? heldMode : attemptMode;
+ requests.push({ path: url.pathname, query: url.search, mode });
+ if (mode.startsWith('hold-'))
+ await new Promise((resolve) => releases.push({ isHeld, resolve }));
+ if (mode.endsWith('denied'))
+ return route.fulfill({
+ status: isHeld ? 403 : 503,
+ json: { error: 'Synthetic unavailable evidence' },
+ });
+ return route.fulfill({
+ json: isHeld
+ ? mode.endsWith('known')
+ ? held
+ : []
+ : log(mode.endsWith('known') ? [sent] : []),
+ });
+ }
+ if (url.pathname === '/api/notifications/health')
+ return route.fulfill({
+ json: {
+ overall_healthy: true,
+ queue: {
+ pending: 0,
+ sending: 0,
+ sent: 1,
+ failed: 0,
+ dlq: 0,
+ healthy: true,
+ status: 'healthy',
+ attention_required: 0,
+ reason_codes: [],
+ completed_retention_days: 7,
+ dead_letter_retention_days: 30,
+ counts_are_retention_bounded: true,
+ },
+ },
+ });
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ },
+ });
+ if (url.pathname === '/api/license/entitlements')
+ return route.fulfill({ json: { entitlements: [] } });
+ if (url.pathname === '/api/security/status')
+ return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } });
+ return route.fulfill({ json: [] });
+ });
+ const check = async (label, action) => {
+ await action();
+ checks.push({ label, passed: true });
+ };
+ const activity = page.locator('#notification-delivery-activity');
+ const empty = activity.getByText(/No alert deliveries were attempted/);
+ const heldWarning = activity.getByText(/could not read held or deferred notifications/);
+ const attemptWarning = activity.getByText(/could not read the delivery log/);
+ const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
+ const release = (isHeld) => {
+ for (const pending of releases.splice(0).filter((item) => {
+ if (item.isHeld === isHeld) {
+ item.resolve();
+ return false;
+ }
+ return true;
+ }))
+ releases.push(pending);
+ };
+ const capture = async (state) => {
+ const filename = `${name}-${state}.png`;
+ await activity.screenshot({ path: path.join(output, filename) });
+ screens.push({
+ file: filename,
+ state,
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ sha256: crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.join(output, filename)))
+ .digest('hex'),
+ });
+ };
+ try {
+ await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html');
+ await page.evaluate(
+ (dark) => document.documentElement.classList.toggle('dark', dark),
+ dark,
+ );
+ await check('Initial reads are loading, not empty', async () => {
+ await activity.getByText('Loading delivery attempts...').waitFor();
+ await activity.getByText('Loading held and deferred notifications...').waitFor();
+ assert.equal(await empty.count(), 0);
+ });
+ await check(
+ 'A slow held read does not block the successful attempts read or its refresh',
+ async () => {
+ release(false);
+ await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' });
+ assert.equal(await refresh.isEnabled(), true);
+ assert.equal(await empty.count(), 0);
+ await activity.getByText('Loading held and deferred notifications...').waitFor();
+ },
+ );
+ await capture('held-pending');
+ await check('Only two completed empty reads establish the empty window', async () => {
+ release(true);
+ await empty.waitFor();
+ assert.equal(await activity.getByRole('status').count(), 0);
+ });
+ await check(
+ 'Keyboard or touch refresh shows attempts and held events from their real API adapters',
+ async () => {
+ attemptMode = heldMode = 'known';
+ if (width === 390) await refresh.tap();
+ else {
+ await refresh.focus();
+ await page.keyboard.press('Enter');
+ }
+ await activity.getByText('Delivered', { exact: true }).waitFor();
+ await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
+ assert.equal(await empty.count(), 0);
+ },
+ );
+ await check(
+ 'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
+ async () => {
+ heldMode = 'denied';
+ await refresh.click();
+ await heldWarning.waitFor();
+ assert.equal(
+ await activity.getByText('synthetic-nas (usage)', { exact: true }).count(),
+ 0,
+ );
+ assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
+ assert.equal(await empty.count(), 0);
+ },
+ );
+ await capture('held-unavailable');
+ await check('The held warning persists during a pending retry', async () => {
+ heldMode = 'hold-empty';
+ await refresh.click();
+ await activity.getByText('Loading held and deferred notifications...').waitFor();
+ assert.equal(await heldWarning.count(), 1);
+ await refresh.waitFor({ state: 'visible' });
+ await page.waitForFunction(
+ () => !document.querySelector('#notification-delivery-activity button').disabled,
+ );
+ assert.equal(await empty.count(), 0);
+ release(true);
+ await heldWarning.waitFor({ state: 'hidden' });
+ });
+ await check(
+ 'An attempt-read failure keeps independently readable held evidence',
+ async () => {
+ attemptMode = 'denied';
+ heldMode = 'known';
+ await refresh.click();
+ await attemptWarning.waitFor();
+ await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor();
+ assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0);
+ assert.equal(await empty.count(), 0);
+ },
+ );
+ await capture('attempts-unavailable');
+ await check('Both failed sources are reported, not called empty', async () => {
+ heldMode = 'denied';
+ await refresh.click();
+ await heldWarning.waitFor();
+ assert.equal(await attemptWarning.count(), 1);
+ assert.equal(await activity.getByRole('listitem').count(), 0);
+ assert.equal(await empty.count(), 0);
+ });
+ await check(
+ 'Both successful reads clear the warnings and restore a genuine empty state',
+ async () => {
+ attemptMode = heldMode = 'empty';
+ await refresh.click();
+ await empty.waitFor();
+ assert.equal(await activity.getByRole('alert').count(), 0);
+ assert.equal(await activity.getByRole('status').count(), 0);
+ },
+ );
+ await check(
+ 'No horizontal clipping, JavaScript page error, off-origin request or mutation',
+ async () => {
+ assert.equal(
+ await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
+ true,
+ );
+ assert.deepEqual(errors, []);
+ assert.deepEqual(offOrigin, []);
+ assert.deepEqual(writes, []);
+ assert(
+ requests
+ .filter((r) => r.path === '/api/alerts/events')
+ .every(
+ (r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'),
+ ),
+ );
+ assert(
+ requests
+ .filter((r) => r.path === '/api/notifications/delivery-log')
+ .every((r) => r.query === '?limit=200'),
+ );
+ },
+ );
+ results.push({
+ name,
+ browser_version: browser.version(),
+ dark,
+ checks,
+ requests,
+ errors,
+ offOrigin,
+ writes,
+ });
+ } finally {
+ for (const pending of releases) pending.resolve();
+ await browser.close();
+ browser = null;
+ }
+ }
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ passed: true,
+ playwright_version: playwrightVersion,
+ content_sha256: hashes,
+ changed_paths: runtime,
+ results,
+ screenshots: screens,
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ console.log(
+ JSON.stringify({
+ passed: true,
+ checks: results.reduce((n, r) => n + r.checks.length, 0),
+ screenshots: screens.length,
+ playwright_version: playwrightVersion,
+ results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })),
+ }),
+ );
+ } catch (error) {
+ fs.writeFileSync(
+ path.join(output, 'failed.json'),
+ JSON.stringify(
+ { passed: false, error: error.message, results, screenshots: screens },
+ null,
+ 2,
+ ) + '\n',
+ );
+ throw error;
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/notification-evidence.html b/frontend-modern/browser-tests/notification-evidence.html
new file mode 100644
index 0000000000..71c910e8fe
--- /dev/null
+++ b/frontend-modern/browser-tests/notification-evidence.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Notification evidence verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/notification-evidence.tsx b/frontend-modern/browser-tests/notification-evidence.tsx
new file mode 100644
index 0000000000..cd358e54b1
--- /dev/null
+++ b/frontend-modern/browser-tests/notification-evidence.tsx
@@ -0,0 +1,69 @@
+// The production Notifications tab and API readers with synthetic read responses.
+// No notification is sent, retried or dismissed by this fixture.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab';
+import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types';
+import '../src/index.css';
+
+function Fixture() {
+ const [emailConfig, setEmailConfig] = createSignal({
+ enabled: false,
+ provider: 'smtp',
+ server: '',
+ port: 587,
+ username: '',
+ password: '',
+ from: '',
+ to: [],
+ tls: true,
+ startTLS: true,
+ replyTo: '',
+ maxRetries: 3,
+ retryDelay: 5,
+ rateLimit: 60,
+ });
+ const [appriseConfig, setAppriseConfig] = createSignal({
+ enabled: false,
+ mode: 'cli',
+ targetsText: '',
+ cliPath: '',
+ timeoutSeconds: 20,
+ serverUrl: '',
+ configKey: '',
+ apiKey: '',
+ apiKeyHeader: 'X-API-KEY',
+ skipTlsVerify: false,
+ hasApiKey: false,
+ });
+ return (
+
+ Notifications
+ {}}
+ configLoadError={() => null}
+ isRetrying={() => false}
+ isLoadingDestinations={() => false}
+ onRetryLoad={() => {}}
+ webhooks={() => []}
+ deadManPingUrl={() => ''}
+ setDeadManPingUrl={() => {}}
+ pushMinimumSeverity={() => 'all'}
+ setPushMinimumSeverity={() => {}}
+ />
+
+ );
+}
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-tests/operator-tables-consent.cjs b/frontend-modern/browser-tests/operator-tables-consent.cjs
new file mode 100644
index 0000000000..606dafe4be
--- /dev/null
+++ b/frontend-modern/browser-tests/operator-tables-consent.cjs
@@ -0,0 +1,419 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const root = '/workspace/frontend-modern';
+ const output = '/workspace/tmp/operator-tables-proof';
+ fs.mkdirSync(output, { recursive: true });
+ process.chdir(root);
+ const playwright = require('playwright/package.json').version;
+ assert.equal(
+ playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json')));
+ const hashes = Object.fromEntries(
+ paths.map((p) => [
+ p,
+ crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.join('/workspace', p)))
+ .digest('hex'),
+ ]),
+ );
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5271, strictPort: true },
+ });
+ const results = [],
+ screens = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width] of [
+ ['desktop', chromium, 1365],
+ ['phone', webkit, 390],
+ ['intermediate', chromium, 768],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ isMobile: width === 390,
+ hasTouch: width === 390,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
+ const errors = [],
+ offOrigin = [],
+ checks = [],
+ requests = [];
+ let rollbackMode = 'reject',
+ finishRollback;
+ page.on('pageerror', (e) => errors.push(e.message));
+ page.setDefaultTimeout(20000);
+ await page.route('**/*', async (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5271') {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname === '/api/updates/history')
+ return route.fulfill({
+ json: [
+ {
+ event_id: 'fixture-update-1',
+ timestamp: '2026-10-02T12:00:00Z',
+ action: 'update',
+ channel: 'stable',
+ version_from: '6.4.5',
+ version_to: '6.4.6',
+ deployment_type: 'systemd',
+ initiated_by: 'user',
+ initiated_via: 'ui',
+ status: 'success',
+ duration_ms: 30000,
+ backup_path: '/private/fixture-backup',
+ notes: 'untrusted restore-everything note',
+ },
+ ],
+ });
+ if (url.pathname === '/api/version')
+ return route.fulfill({
+ json: {
+ version: '6.6.0-dev',
+ build: 'synthetic',
+ runtime: 'community',
+ isDocker: false,
+ isSourceBuild: true,
+ isDevelopment: false,
+ },
+ });
+ if (url.pathname === '/api/updates/rollback') {
+ requests.push({ method: route.request().method(), body: route.request().postDataJSON() });
+ if (rollbackMode === 'hold')
+ await new Promise((resolve) => {
+ finishRollback = resolve;
+ });
+ return rollbackMode === 'accept'
+ ? route.fulfill({
+ json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' },
+ })
+ : route.fulfill({
+ status: 409,
+ json: { error: 'Synthetic incompatible backup scope' },
+ });
+ }
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ },
+ });
+ if (url.pathname === '/api/license/entitlements')
+ return route.fulfill({ json: { entitlements: [] } });
+ if (url.pathname.includes('/facets'))
+ return route.fulfill({
+ json: { facets: [], capabilities: [], relationships: [], recentChanges: [] },
+ });
+ if (url.pathname === '/api/security/status')
+ return route.fulfill({
+ json: {
+ hasAuthentication: true,
+ requiresAuth: true,
+ settingsCapabilities: { authenticationWrite: true },
+ },
+ });
+ if (url.pathname === '/api/metrics-store/history')
+ return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } });
+ return route.fulfill({
+ json: {
+ enabled: false,
+ data: [],
+ alerts: {},
+ facets: [],
+ capabilities: [],
+ relationships: [],
+ recentChanges: [],
+ },
+ });
+ });
+ const check = async (label, run) => {
+ try {
+ await run();
+ checks.push({ label, passed: true });
+ } catch (e) {
+ checks.push({ label, passed: false, error: e.message });
+ }
+ };
+ const select = async (name) => {
+ await page
+ .getByRole('navigation', { name: 'Fixture views' })
+ .getByRole('button', { name, exact: true })
+ .click();
+ };
+ const capture = async (state) => {
+ // Await the production entrance animation instead of capturing translucent frames.
+ if (await page.getByRole('dialog').count()) {
+ await page.waitForFunction(() => {
+ const panel = document.querySelector('[role=dialog]');
+ return panel && Number(getComputedStyle(panel).opacity) >= 0.999;
+ });
+ }
+ await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof.
+ const p = `${name}-${state}.png`;
+ await page.screenshot({ path: path.join(output, p), fullPage: true });
+ const dimensions = await page.evaluate(() => ({
+ width: innerWidth,
+ scroll: document.documentElement.scrollWidth,
+ }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ screens.push({ file: p, state, browser: name, width, dimensions });
+ };
+ await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 120000,
+ });
+ if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ await check('Pool state and measured capacity survive the phone projection', async () => {
+ const pool = page.getByRole('region', { name: 'Pool table' });
+ await pool.getByText('archive-tank', { exact: true }).waitFor();
+ await pool.getByText('Degraded', { exact: true }).waitFor();
+ const state = pool.locator('tbody td[data-storage-column="state"]').first();
+ const bounds = await state.evaluate((e) => ({
+ width: e.clientWidth,
+ scroll: e.scrollWidth,
+ text: e.textContent,
+ }));
+ assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
+ if (width >= 544) {
+ await pool.getByText('Resilvering 45%', { exact: true }).waitFor();
+ await pool.getByText('Scrubbing 45%', { exact: true }).waitFor();
+ assert.ok(
+ await pool
+ .locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]')
+ .count(),
+ );
+ } else {
+ assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2);
+ }
+ await capture('pools');
+ });
+ if (width !== 768) {
+ await select('Disks');
+ await check('Disk row and expanded header retain the full SMART reason', async () => {
+ await page.getByText('Archive HDD', { exact: true }).waitFor();
+ await page
+ .locator('[data-row-id="disk-sda"]')
+ .getByText('Archive HDD', { exact: true })
+ .click();
+ const health = page.getByTestId('disk-detail-health');
+ await health.getByText('Replace Now', { exact: true }).waitFor();
+ assert.ok(
+ (await health.innerText()).includes('SMART failed. The full reason remains readable'),
+ );
+ const bounds = await health.evaluate((e) => ({
+ width: e.clientWidth,
+ scroll: e.scrollWidth,
+ }));
+ assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
+ await capture('disk-reason');
+ });
+ await select('Controllers');
+ await check(
+ 'Job target and absolute started/completed times are reachable from a narrow row',
+ async () => {
+ await page.getByText('nightly-import', { exact: true }).click();
+ const details = page.getByTestId('resource-kubernetes-controller-section');
+ await details.getByText('Started', { exact: true }).waitFor();
+ await details.getByText('Completed', { exact: true }).waitFor();
+ await details.getByText('5m', { exact: true }).waitFor();
+ await details.getByText('1 completion', { exact: true }).waitFor();
+ assert.ok((await details.innerText()).includes('2026'));
+ await capture('job-times');
+ },
+ );
+ await check(
+ 'CronJob retains last-run, last-success, schedule and suspension in its expansion',
+ async () => {
+ await page.getByText('billing-rollup', { exact: true }).click();
+ const details = page.getByTestId('resource-kubernetes-controller-section');
+ await details.getByText('Last run', { exact: true }).waitFor();
+ await details.getByText('Last success', { exact: true }).waitFor();
+ await details.getByText('*/5 * * * *', { exact: true }).waitFor();
+ await details.getByText('Suspended', { exact: true }).waitFor();
+ await capture('cron-times');
+ },
+ );
+ }
+ await select('Backups');
+ await check(
+ 'Coverage last backup remains older than a fresh local guest snapshot',
+ async () => {
+ const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
+ await coverage.waitFor();
+ const columns = await coverage
+ .locator('colgroup col')
+ .evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn));
+ const row = coverage.locator('tr[data-proxmox-backup-row=coverage]');
+ const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
+ assert.ok(backupAge.includes('13d'), backupAge);
+ assert.ok(!backupAge.includes('30m'), backupAge);
+ if (columns.includes('snapshot')) {
+ assert.ok(
+ (await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes(
+ '30m',
+ ),
+ );
+ }
+ await capture('backup-attribution');
+ },
+ );
+ await check(
+ 'By date keeps archive format visible and full identifier available at wide widths',
+ async () => {
+ const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
+ await byDate.waitFor();
+ if (width >= 1120) {
+ await byDate.getByText('tar.zst', { exact: true }).waitFor();
+ assert.ok(
+ await byDate
+ .locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]')
+ .count(),
+ );
+ }
+ assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2);
+ },
+ );
+ if (width !== 768) {
+ await select('Consent');
+ await check(
+ 'Consent separates running/backup versions and does not promise active-data restoration',
+ async () => {
+ const action = page.getByRole('button', { name: 'Roll back', exact: true });
+ await action.focus();
+ await page.keyboard.press('Enter');
+ const dialog = page.getByRole('dialog', { name: 'Confirm rollback' });
+ await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor();
+ await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor();
+ const text = await dialog.innerText();
+ assert.ok(text.includes('Restore scope is not reported for this backup.'));
+ assert.ok(text.includes('Older recovery can also replace install-local data'));
+ assert.ok(text.includes('Do not assume this will undo settings or alerts.'));
+ assert.ok(!text.includes('/private/fixture-backup'));
+ assert.ok(!text.includes('untrusted restore-everything note'));
+ assert.equal(
+ await dialog
+ .getByRole('link', { name: 'Read the recovery instructions' })
+ .getAttribute('href'),
+ '/docs/AUTO_UPDATE#manual-rollback',
+ );
+ assert.equal(requests.length, 0);
+ await capture('consent');
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
+ assert.equal(await page.getByRole('dialog').count(), 0);
+ assert.equal(requests.length, 0);
+ assert.equal(await action.evaluate((e) => e === document.activeElement), true);
+ },
+ );
+ await check(
+ 'Rejection retains consent and pending submission cannot be duplicated or cancelled',
+ async () => {
+ await page.getByRole('button', { name: 'Roll back', exact: true }).click();
+ await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
+ await page
+ .getByText('Synthetic incompatible backup scope', { exact: true })
+ .first()
+ .waitFor();
+ assert.equal(requests.length, 1);
+ assert.ok(
+ (await page.getByRole('dialog').innerText()).includes(
+ 'Restore scope is not reported',
+ ),
+ );
+ await capture('consent-rejected');
+ rollbackMode = 'hold';
+ await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
+ await page.waitForFunction(
+ () => document.querySelector('button:disabled')?.textContent,
+ );
+ assert.ok(
+ await page
+ .getByRole('button', { name: 'Starting rollback...', exact: true })
+ .isDisabled(),
+ );
+ assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled());
+ await page.keyboard.press('Escape');
+ assert.equal(await page.getByRole('dialog').count(), 1);
+ while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(requests.length, 2);
+ rollbackMode = 'accept';
+ finishRollback();
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
+ assert.deepEqual(requests, [
+ { method: 'POST', body: { eventId: 'fixture-update-1' } },
+ { method: 'POST', body: { eventId: 'fixture-update-1' } },
+ ]);
+ },
+ );
+ }
+ await check('No uncaught application errors or external requests', async () => {
+ assert.deepEqual(errors, []);
+ assert.deepEqual(offOrigin, []);
+ });
+ results.push({
+ name,
+ width,
+ browser: browser.version(),
+ checks,
+ errors,
+ offOrigin,
+ rollbackRequests: requests,
+ });
+ await page.close();
+ await browser.close();
+ browser = undefined;
+ }
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+ const result = {
+ result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed',
+ playwright,
+ base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4',
+ source_content_sha256: hashes,
+ results,
+ screens,
+ };
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
+ console.log(
+ JSON.stringify({
+ result: result.result,
+ checks: results.flatMap((r) => r.checks).length,
+ failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)),
+ screens: screens.length,
+ }),
+ );
+ if (result.result !== 'passed') process.exitCode = 1;
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/operator-tables-consent.html b/frontend-modern/browser-tests/operator-tables-consent.html
new file mode 100644
index 0000000000..4aabdd1bcb
--- /dev/null
+++ b/frontend-modern/browser-tests/operator-tables-consent.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Operator tables and rollback consent verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/operator-tables-consent.tsx b/frontend-modern/browser-tests/operator-tables-consent.tsx
new file mode 100644
index 0000000000..17b99932c1
--- /dev/null
+++ b/frontend-modern/browser-tests/operator-tables-consent.tsx
@@ -0,0 +1,399 @@
+// Production components and CSS with synthetic observations. No native, released or
+// installed recovery claim; the current history API does not describe restore scope.
+import { createSignal, Show } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
+import { DiskList } from '../src/components/Storage/DiskList';
+import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable';
+import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable';
+import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable';
+import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
+import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
+import { GuestRow } from '../src/components/Workloads/GuestRow';
+import { Table, TableBody } from '../src/components/shared/Table';
+import { ToastContainer } from '../src/components/Toast/Toast';
+import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection';
+import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel';
+import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
+import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
+import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
+import { updateStore } from '../src/stores/updates';
+import type { Resource } from '../src/types/resource';
+import type { StorageRecord } from '../src/features/storageBackups/models';
+import '../src/index.css';
+
+const now = Date.parse('2026-10-03T12:00:00Z');
+const resource = (id: string, type: Resource['type'], extra: Partial = {}): Resource => ({
+ id,
+ type,
+ name: id,
+ displayName: id,
+ platformId: 'fixture-estate',
+ platformType: 'proxmox-pve',
+ sourceType: 'api',
+ status: 'online',
+ lastSeen: now,
+ ...extra,
+});
+const pools: StorageRecord[] = [
+ ['archive-tank', 'DEGRADED', 'resilver'],
+ ['backup-pool', 'ONLINE', 'scrub'],
+].map(([name, state, scan], index) => ({
+ id: name,
+ name,
+ category: 'pool',
+ health: index ? 'healthy' : 'warning',
+ location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' },
+ source: {
+ platform: 'truenas',
+ family: 'onprem',
+ origin: 'resource',
+ adapterId: 'resource-storage',
+ },
+ capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 },
+ capabilities: ['capacity', 'health'],
+ observedAt: now,
+ protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`,
+ protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`,
+ rebuildInProgress: true,
+ details: {
+ zfsPool: {
+ state,
+ scan: `${scan} in progress`,
+ scanDetails: {
+ function: scan.toUpperCase(),
+ state: 'SCANNING',
+ percentage: 45.2,
+ },
+ devices: [],
+ },
+ },
+}));
+const disks = [
+ resource('disk-sda', 'physical_disk', {
+ identity: { hostname: 'archive-nas' },
+ canonicalIdentity: { hostname: 'archive-nas' },
+ physicalDisk: {
+ devPath: '/dev/sda',
+ model: 'Archive HDD',
+ serial: 'FIXTURE-1',
+ diskType: 'hdd',
+ sizeBytes: 2e12,
+ health: 'FAILED',
+ temperature: 42,
+ storageRole: 'data',
+ risk: {
+ level: 'critical',
+ reasons: [
+ {
+ code: 'smart-failed',
+ severity: 'critical',
+ summary: 'SMART failed. The full reason remains readable in the expanded disk header.',
+ },
+ ],
+ },
+ },
+ }),
+ resource('disk-nvme', 'physical_disk', {
+ identity: { hostname: 'backup-nas' },
+ canonicalIdentity: { hostname: 'backup-nas' },
+ physicalDisk: {
+ devPath: '/dev/nvme0n1',
+ model: 'Backup SSD',
+ serial: 'FIXTURE-2',
+ diskType: 'nvme',
+ sizeBytes: 1e12,
+ health: 'PASSED',
+ temperature: 44,
+ wearout: 2,
+ storageRole: 'cache',
+ },
+ }),
+];
+const controllers = [
+ resource('nightly-import', 'k8s-job', {
+ platformType: 'kubernetes',
+ kubernetes: {
+ resourceKind: 'Job',
+ clusterName: 'prod',
+ namespace: 'batch',
+ startTime: '2026-10-03T11:00:00Z',
+ completionTime: '2026-10-03T11:05:00Z',
+ desiredReplicas: 1,
+ succeeded: 1,
+ active: 0,
+ failed: 0,
+ },
+ }),
+ resource('billing-rollup', 'k8s-cronjob', {
+ platformType: 'kubernetes',
+ kubernetes: {
+ resourceKind: 'CronJob',
+ clusterName: 'prod',
+ namespace: 'batch',
+ schedule: '*/5 * * * *',
+ lastScheduleTime: '2026-10-03T11:00:00Z',
+ lastSuccessfulTime: '2026-10-03T11:05:00Z',
+ suspend: true,
+ active: 0,
+ },
+ }),
+ resource('node-exporter', 'k8s-daemonset', {
+ platformType: 'kubernetes',
+ kubernetes: {
+ resourceKind: 'DaemonSet',
+ clusterName: 'prod',
+ namespace: 'observability',
+ desiredNumberScheduled: 3,
+ currentNumberScheduled: 3,
+ numberReady: 2,
+ numberUnavailable: 1,
+ },
+ }),
+ resource('database', 'k8s-statefulset', {
+ platformType: 'kubernetes',
+ kubernetes: {
+ resourceKind: 'StatefulSet',
+ clusterName: 'prod',
+ namespace: 'apps',
+ desiredReplicas: 1,
+ readyReplicas: 1,
+ serviceName: 'database-headless',
+ },
+ }),
+];
+const workload = resource('ct-310', 'system-container', {
+ name: 'artifact-cache-310',
+ displayName: 'artifact-cache-310',
+ status: 'running',
+ proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
+});
+const model = buildProxmoxBackupRecoveryModel({
+ workloads: [workload],
+ pbsBackups: [],
+ tasks: [],
+ nowMs: now,
+ archives: [
+ {
+ id: 'file-310',
+ storage: 'local',
+ node: 'pve-edge',
+ instance: 'homelab',
+ type: 'ct',
+ vmid: 310,
+ time: '2026-09-20T02:00:00Z',
+ ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
+ size: 1048576,
+ format: 'tar.zst',
+ protected: false,
+ volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
+ isPBS: false,
+ verified: false,
+ },
+ ],
+ snapshots: [
+ {
+ id: 'snap-310',
+ name: 'fresh-local-snapshot',
+ node: 'pve-edge',
+ instance: 'homelab',
+ type: 'ct',
+ vmid: 310,
+ time: '2026-10-03T11:30:00Z',
+ vmstate: false,
+ },
+ ],
+});
+const pbs = resource('pbs-archive', 'pbs', {
+ platformType: 'proxmox-pbs',
+ cpu: { current: 14 },
+ memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
+ pbs: {
+ instance: 'pbs-archive',
+ datastores: [
+ {
+ name: 'archive',
+ total: 2e12,
+ used: 1.18e12,
+ free: 0.82e12,
+ usage: 59,
+ status: 'available',
+ },
+ ],
+ },
+});
+const node = resource('pve-edge', 'node', {
+ cpu: { current: 12, cores: 4 },
+ memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
+ disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 },
+ proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 },
+});
+function Fixture() {
+ const [view, setView] = createSignal('Pools');
+ const [disk, setDisk] = createSignal(null);
+ const [pool, setPool] = createSignal(null);
+ const [expanded, setExpanded] = createSignal(new Set());
+ const ws = {
+ state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
+ activeAlerts: () => ({}),
+ connected: () => true,
+ initialDataReceived: () => true,
+ };
+ return (
+
+ document.documentElement.classList.contains('dark')}>
+
+
+ Operator tables and rollback consent
+
+ {['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
+ setView(name)}
+ >
+ {name}
+
+ ))}
+
+
+
+ {}}
+ expandedPoolId={pool()}
+ setExpandedPoolId={setPool}
+ storageGrowthBySeriesId={new Map()}
+ storageGrowthColumnLabel="Growth"
+ physicalDisks={[]}
+ nodeOnlineByLabel={new Map()}
+ highlightedRecordId={null}
+ getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
+ isLoading={false}
+ />
+
+
+
+
+
+
+
+ }
+ emptyTitle="No controllers"
+ emptyDescription="No controllers"
+ showToolbar={false}
+ />
+
+
+
+
+ Coverage
+ }
+ emptyTitle="No backups"
+ emptyDescription="No backups"
+ sortKey={() => 'posture'}
+ sortDirection={() => 'asc'}
+ onSort={() => {}}
+ expandedKeys={expanded()}
+ onToggleExpand={(key) =>
+ setExpanded((current) =>
+ current.has(key)
+ ? new Set([...current].filter((item) => item !== key))
+ : new Set([...current, key]),
+ )
+ }
+ showPbsColumn
+ showArchiveColumn
+ showSnapshotColumn
+ showTaskColumn={false}
+ />
+ By date
+ }
+ emptyTitle="No artifacts"
+ emptyDescription="No artifacts"
+ sortKey={() => 'created'}
+ sortDirection={() => 'desc'}
+ onSort={() => {}}
+ sizeMaxBytes={1048576}
+ />
+ Backup servers
+
+ Proxmox nodes
+ }
+ emptyTitle="No nodes"
+ emptyDescription="No nodes"
+ />
+ Workload row
+
+
+
+
+
+
+
+
+
+ );
+}
+void updateStore.checkForUpdates();
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs
new file mode 100644
index 0000000000..bcc376eded
--- /dev/null
+++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs
@@ -0,0 +1,497 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+const now = '2026-10-03T12:00:00Z';
+const base = (id, type, extra = {}) => ({
+ id,
+ type,
+ name: id,
+ status: 'online',
+ lastSeen: now,
+ sources: ['docker'],
+ ...extra,
+});
+const observations = (mode) => [
+ base('mail-gateway-eu', 'pmg', {
+ sources: ['pmg'],
+ technology: 'pmg',
+ uptime: 86400,
+ sourceStatus: { pmg: { status: 'online', lastSeen: now } },
+ ...(mode === 'missing'
+ ? {}
+ : {
+ pmg: {
+ instanceId: 'pmg-eu',
+ hostname: 'mail-gateway-eu',
+ version: '8.1-2',
+ nodeCount: 2,
+ mailCountTotal: 2740,
+ spamIn: 321,
+ virusIn: 14,
+ quarantine: 3,
+ queueTotal: mode === 'fresh' ? 19 : 11,
+ queueDeferred: 5,
+ },
+ }),
+ }),
+ base('nfs-iso', 'storage', {
+ sources: ['proxmox'],
+ parentName: 'cluster',
+ parentId: 'pve-cluster-a',
+ technology: 'nfs',
+ metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } },
+ storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] },
+ }),
+ base('local-lvm', 'storage', {
+ sources: ['proxmox'],
+ parentName: 'pve2',
+ parentId: 'pve2',
+ metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } },
+ storage: { type: 'lvmthin', shared: false },
+ }),
+ base('edge-web', 'app-container', {
+ status: 'running',
+ technology: 'docker',
+ sourceStatus: { docker: { status: 'online', lastSeen: now } },
+ actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }],
+ metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } },
+ docker: {
+ agentId: 'agent-edge',
+ hostSourceId: 'agent-edge',
+ containerId: 'native-edge-web',
+ hostname: 'edge-long-hostname',
+ runtime: 'docker',
+ runtimeVersion: '27.5.1',
+ image: 'nginx:latest',
+ containerState: 'running',
+ restartCount: 7,
+ updateStatus: {
+ updateAvailable: true,
+ currentDigest: 'sha256:current',
+ latestDigest: 'sha256:latest',
+ lastChecked: now,
+ },
+ },
+ }),
+ base('edge-cache', 'app-container', {
+ status: 'offline',
+ metrics: { cpu: { percent: 0 }, memory: { percent: 0 } },
+ docker: {
+ agentId: 'agent-edge',
+ hostname: 'edge-long-hostname',
+ runtime: 'podman',
+ runtimeVersion: '5.2.1',
+ image: 'redis:7.4',
+ containerState: 'exited',
+ restartCount: 0,
+ },
+ }),
+ base('nginx:latest', 'docker-image', {
+ docker: {
+ hostname: 'edge-long-hostname',
+ imageId: 'image-nginx',
+ repoTags: ['nginx:latest'],
+ sizeBytes: 2e8,
+ containers: ['edge-web'],
+ containerCount: 1,
+ },
+ }),
+ base('web-service', 'docker-service', {
+ docker: {
+ hostname: 'edge-long-hostname',
+ image: 'nginx:latest',
+ serviceMode: 'replicated',
+ desiredTasks: 4,
+ runningTasks: 2,
+ mode: 'replicated',
+ serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' },
+ },
+ }),
+ base('web.2', 'docker-task', {
+ docker: {
+ hostname: 'edge-long-hostname',
+ serviceName: 'web-service',
+ nodeName: 'worker-1',
+ currentState: 'running',
+ desiredState: 'running',
+ startedAt: '2026-10-03T11:30:00.123456Z',
+ },
+ }),
+];
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ const output = '/workspace/tmp/proxmox-docker-rows-final-source';
+ fs.mkdirSync(output, { recursive: true });
+ process.chdir(root);
+ const playwright = require('playwright/package.json').version;
+ assert.equal(
+ playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ // Keep the fixture runnable from clean committed source, without requiring
+ // an invocation-specific untracked runtime-paths input.
+ const paths = [
+ 'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
+ 'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
+ 'frontend-modern/src/features/docker/DockerImagesTable.tsx',
+ 'frontend-modern/src/features/docker/DockerServicesTable.tsx',
+ 'frontend-modern/src/features/docker/DockerTasksTable.tsx',
+ 'frontend-modern/src/features/docker/dockerContainerTableModel.ts',
+ 'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts',
+ 'frontend-modern/src/features/storageBackups/storageAdapters.ts',
+ 'frontend-modern/src/hooks/useUnifiedResources.ts',
+ ];
+ const hashes = Object.fromEntries(
+ paths.map((p) => [
+ p,
+ crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.join('/workspace', p)))
+ .digest('hex'),
+ ]),
+ );
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5272, strictPort: true },
+ });
+ const results = [],
+ screenshots = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width] of [
+ ['desktop', chromium, 1440],
+ ['phone', webkit, 390],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ isMobile: width === 390,
+ hasTouch: width === 390,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ await page.clock.setFixedTime(new Date(now));
+ page.setDefaultTimeout(15000);
+ const checks = [],
+ errors = [],
+ offOrigin = [],
+ mutations = [];
+ let mode = 'initial';
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.route('**/*', async (route) => {
+ const request = route.request(),
+ url = new URL(request.url());
+ if (url.origin !== 'http://127.0.0.1:5272') {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (request.method() !== 'GET') {
+ mutations.push({ path: url.pathname, method: request.method() });
+ return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } });
+ }
+ if (url.pathname === '/api/resources')
+ return route.fulfill({ json: { data: observations(mode) } });
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ blocked_capabilities: [],
+ runtime: { build: 'community' },
+ },
+ });
+ if (url.pathname === '/api/security/status')
+ return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } });
+ return route.fulfill({
+ json: {
+ enabled: false,
+ data: [],
+ capabilities: [],
+ facets: [],
+ relationships: [],
+ recentChanges: [],
+ alerts: {},
+ },
+ });
+ });
+ const check = async (label, run) => {
+ try {
+ await run();
+ checks.push({ label, passed: true });
+ } catch (e) {
+ checks.push({ label, passed: false, error: e.message });
+ }
+ };
+ const select = (view) =>
+ page
+ .getByRole('navigation', { name: 'Fixture views' })
+ .getByRole('button', { name: view, exact: true })
+ .click();
+ const capture = async (state) => {
+ await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence.
+ const dimensions = await page.evaluate(() => ({
+ width: innerWidth,
+ scroll: document.documentElement.scrollWidth,
+ }));
+ checks.push({
+ label: `No horizontal page overflow: ${state}`,
+ passed: dimensions.scroll <= dimensions.width + 1,
+ dimensions,
+ });
+ const file = `${name}-${state}.png`;
+ await page.screenshot({ path: path.join(output, file), fullPage: true });
+ screenshots.push({ file, state, engine: name, width, dimensions });
+ };
+ await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 120000,
+ });
+ if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ await page.getByText('8 REST-mapped rows', { exact: true }).waitFor();
+ await check(
+ 'REST-first PMG counters and facets are present before any websocket hydration',
+ async () => {
+ const table = page.getByRole('region', { name: 'Mail table' });
+ await table.getByText('2,740', { exact: true }).waitFor();
+ await table.getByText('11', { exact: true }).waitFor();
+ const row = await page.evaluate(() =>
+ window.__operatorRows.rows().find((r) => r.type === 'pmg'),
+ );
+ assert.equal(row.technology, 'pmg');
+ assert.equal(row.sourceStatus.pmg.status, 'online');
+ assert.equal(row.pmg.queueTotal, 11);
+ assert.deepEqual(row.pmg, row.platformData.pmg);
+ if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor();
+ },
+ );
+ await capture('rest-mail');
+ await check(
+ 'Canonical store hydration followed by REST refresh keeps the new PMG counters',
+ async () => {
+ await page.evaluate(() => window.__operatorRows.seedWebsocket());
+ mode = 'fresh';
+ await page.evaluate(() => window.__operatorRows.refresh());
+ await page
+ .getByRole('region', { name: 'Mail table' })
+ .getByText('19', { exact: true })
+ .waitFor();
+ },
+ );
+ await capture('refreshed-mail');
+ await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => {
+ mode = 'missing';
+ await page.evaluate(() => window.__operatorRows.refresh());
+ assert.equal(
+ await page
+ .getByRole('region', { name: 'Mail table' })
+ .getByText('19', { exact: true })
+ .count(),
+ 0,
+ );
+ const row = await page.evaluate(() =>
+ window.__operatorRows.rows().find((r) => r.type === 'pmg'),
+ );
+ assert.equal(row.pmg, undefined);
+ mode = 'fresh';
+ await page.evaluate(() => window.__operatorRows.refresh());
+ });
+ await select('Storage');
+ await check('Shared storage vocabulary preserves native locations and IDs', async () => {
+ const table = page.getByRole('region', { name: 'Storage table' });
+ await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' });
+ await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' });
+ await table.getByText('Shared · 3 nodes', { exact: true }).waitFor({ state: 'attached' });
+ await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' });
+ const rows = await page.evaluate(() =>
+ window.__operatorRows.rows().filter((r) => r.type === 'storage'),
+ );
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.parentId]),
+ [
+ ['nfs-iso', 'pve-cluster-a'],
+ ['local-lvm', 'pve2'],
+ ],
+ );
+ });
+ await capture('storage');
+ await select('Containers');
+ await check(
+ 'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail',
+ async () => {
+ await page
+ .locator('[data-lifecycle-refusal]')
+ .getByText('Command agent disconnected', { exact: true })
+ .waitFor();
+ const table = page.getByRole('region', { name: 'Container table' });
+ const update = table.locator('.docker-container-update-cell button');
+ await update.waitFor();
+ assert.match(await update.innerText(), /Update/);
+ const bounds = await update.evaluate((e) => ({
+ w: e.getBoundingClientRect().width,
+ cell: e.closest('td').getBoundingClientRect().width,
+ scroll: e.scrollWidth,
+ client: e.clientWidth,
+ }));
+ assert.ok(
+ bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1,
+ JSON.stringify(bounds),
+ );
+ const headers = await table.locator('th:visible').allTextContents();
+ if (width === 390) {
+ assert.ok(!headers.some((h) => h.includes('Restarts')));
+ assert.ok(headers.some((h) => h.includes('State')));
+ }
+ },
+ );
+ await capture('containers');
+ await check(
+ 'Restart count and native identity remain reachable via row disclosure',
+ async () => {
+ const row = page.locator('[data-docker-container-row="edge-web"]');
+ if (width === 390) await row.locator('td').first().tap();
+ else await row.getByRole('button', { name: /details/i }).click();
+ const detail = page.getByTestId('resource-docker-container-section');
+ await detail.getByText('Restarts', { exact: true }).waitFor();
+ await detail.getByText('7', { exact: true }).waitFor();
+ },
+ );
+ await capture('container-detail');
+ await select('Images');
+ await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => {
+ const table = page.getByRole('region', { name: 'Image table' });
+ await table.getByText('nginx:latest', { exact: true }).waitFor();
+ const headers = await table.locator('th:visible').allTextContents();
+ const row = table.locator('[data-docker-image-row]');
+ assert.equal(await row.locator('td:visible').count(), headers.length);
+ if (width === 390) {
+ assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by')));
+ assert.ok(headers.some((h) => h.includes('Size')));
+ }
+ });
+ await capture('images');
+ await select('Swarm');
+ await check(
+ 'Swarm state and started time use operator wording with raw evidence retained',
+ async () => {
+ const state = page.getByText('Rollback started', { exact: true });
+ await state.waitFor({ state: 'attached' });
+ assert.match(
+ await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'),
+ /rollback_started/,
+ );
+ const visibleState = await state.evaluate((element) => {
+ const cell = element.closest('td').getBoundingClientRect();
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ return [...range.getClientRects()].every(
+ (line) =>
+ line.left >= cell.left &&
+ line.right <= cell.right &&
+ line.top >= cell.top &&
+ line.bottom <= cell.bottom,
+ );
+ });
+ assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell');
+ if (width === 390) {
+ await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' });
+ }
+ const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]');
+ await started.waitFor({ state: 'attached' });
+ assert.match(await started.innerText(), /ago/);
+ await page
+ .getByRole('region', { name: 'Swarm tasks' })
+ .getByTitle('running', { exact: true })
+ .waitFor();
+ },
+ );
+ await capture('swarm');
+ await check(
+ 'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence',
+ async () => {
+ const row = page.locator('[data-docker-service-row="web-service"]');
+ if (width === 390) await row.locator('td').first().tap();
+ else {
+ const toggle = row.getByRole('button', {
+ name: 'Expand details for web-service',
+ exact: true,
+ });
+ await toggle.focus();
+ await page.keyboard.press('Enter');
+ }
+ const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]');
+ await detail
+ .getByText('rollback_started | Synthetic rollout observation', { exact: true })
+ .waitFor();
+ await detail.getByText('edge-long-hostname', { exact: true }).waitFor();
+ await detail.getByText('4', { exact: true }).waitFor();
+ await detail.getByText('2', { exact: true }).waitFor();
+ },
+ );
+ await capture('swarm-detail');
+ await select('Disk');
+ await check(
+ 'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars',
+ async () => {
+ const label = page.locator('[data-stacked-disk-max-label]');
+ assert.equal(await label.innerText(), '92%');
+ assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%');
+ assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2);
+ },
+ );
+ await capture('disks');
+ checks.push({
+ label: 'No page errors, external requests or mutation requests',
+ passed: !errors.length && !offOrigin.length && !mutations.length,
+ errors,
+ offOrigin,
+ mutations,
+ });
+ results.push({ engine: name, version: browser.version(), width, checks });
+ await browser.close();
+ browser = undefined;
+ }
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+ const result = {
+ playwright,
+ verified_at: new Date().toISOString(),
+ paths,
+ hashes,
+ results,
+ screenshots,
+ };
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
+ const failures = results.flatMap((r) =>
+ r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })),
+ );
+ console.log(
+ JSON.stringify({
+ checked: results.reduce((n, r) => n + r.checks.length, 0),
+ screenshots: screenshots.length,
+ failures,
+ }),
+ );
+ assert.equal(failures.length, 0, JSON.stringify(failures));
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.html b/frontend-modern/browser-tests/proxmox-docker-rows.html
new file mode 100644
index 0000000000..5e96968d98
--- /dev/null
+++ b/frontend-modern/browser-tests/proxmox-docker-rows.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Transport facets and operator rows verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.tsx b/frontend-modern/browser-tests/proxmox-docker-rows.tsx
new file mode 100644
index 0000000000..2f0285c0be
--- /dev/null
+++ b/frontend-modern/browser-tests/proxmox-docker-rows.tsx
@@ -0,0 +1,182 @@
+// Synthetic REST observations through the real hook, tables, detail disclosures
+// and CSS. No real websocket, appliance or installed/released acceptance claim.
+import { createSignal, Show } from 'solid-js';
+import { createStore, unwrap } from 'solid-js/store';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
+import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable';
+import { DockerContainersTable } from '../src/features/docker/DockerContainersTable';
+import { DockerImagesTable } from '../src/features/docker/DockerImagesTable';
+import { DockerServicesTable } from '../src/features/docker/DockerServicesTable';
+import { DockerTasksTable } from '../src/features/docker/DockerTasksTable';
+import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
+import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
+import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table';
+import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions';
+import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters';
+import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
+import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
+import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
+import type { Resource } from '../src/types/resource';
+import '../src/index.css';
+
+const [wsState, setWsState] = createStore({
+ resources: [] as Resource[],
+ lastUpdate: 0,
+ nodes: [],
+ pbs: [],
+ pmg: [],
+ vms: [],
+ containers: [],
+});
+const [connected, setConnected] = createSignal(false);
+const ws = {
+ state: wsState,
+ connected,
+ initialDataReceived: () => true,
+ resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }),
+ activeAlerts: () => ({}),
+ shutdown: () => {},
+};
+window.__pulseWsStore = ws as any;
+
+function Fixture() {
+ const [view, setView] = createSignal('Mail');
+ const [expandedPool, setExpandedPool] = createSignal(null);
+ const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' });
+ const ofType = (type: string) => rows.resources().filter((row) => row.type === type);
+ const container = () => ofType('app-container')[0];
+ const records = () =>
+ buildStorageRecords({ state: wsState as any, resources: ofType('storage') });
+ const grouped = () => groupStorageRecords(records(), 'none');
+ (window as any).__operatorRows = {
+ rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))),
+ refresh: () => rows.refetch(),
+ seedWebsocket: () => {
+ setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources()))));
+ setWsState('lastUpdate', Date.now());
+ setConnected(true);
+ },
+ };
+ const nativeProps = {
+ emptyIcon: ,
+ emptyTitle: 'No fixture rows',
+ emptyDescription: 'No fixture rows',
+ showToolbar: false,
+ };
+ return (
+
+ document.documentElement.classList.contains('dark')}>
+
+ Transport facets and operator rows
+
+ {['Mail', 'Storage', 'Containers', 'Images', 'Swarm', 'Disk'].map((name) => (
+ setView(name)}
+ >
+ {name}
+
+ ))}
+
+ {rows.resources().length} REST-mapped rows
+
+
+
+
+
+ {}}
+ expandedPoolId={expandedPool()}
+ setExpandedPoolId={setExpandedPool}
+ storageGrowthBySeriesId={new Map()}
+ storageGrowthColumnLabel="Growth"
+ physicalDisks={[]}
+ nodeOnlineByLabel={new Map()}
+ highlightedRecordId={null}
+ getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
+ isLoading={false}
+ />
+
+
+
+
+
+ {container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pve-edge
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index bd2176689c..453fe2f009 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,23 +1,24 @@
{
"version": 1,
"result": "passed",
- "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
- "verified_at": "2026-10-03T04:13:25.499572Z",
+ "base_sha": "2d6a10ab59d327b461ef762ec69a9c5d0440929a",
+ "verified_at": "2026-10-03T11:37:53.905511Z",
"changed_paths": [
- "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
- "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
+ "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx",
+ "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts",
+ "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
+ "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
+ "frontend-modern/src/utils/alertDestinationsPresentation.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
- "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
- "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
+ "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "cac90b57e94b42360989262b550c9b0beaca8dd6eecdd7f7882911bb49de9fc2",
+ "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "e8ce66a9ca6ae82861a9d03a7d565f6c246f9b5fc547e7c2e64f0a3e6bd12014",
+ "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "eaf519a7f205be0b0e88ea17b19552f30afef025251262a1ea1b8896442e2673",
+ "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "9278ab7cf249eea451040b722b244d1447b05c07557d641187fa51f3bc88a225",
+ "frontend-modern/src/utils/alertDestinationsPresentation.ts": "5afa5dbce8eb1602377b7b27f6298438da76e3ebf2f80986677c9988bf36c96c"
},
"routes": [
- "Existing PBS/resource detail Overview change history",
- "Existing resource detail Manage action history"
+ "Existing Alerts Notifications tab through production DestinationsTab, destination state, notification/alert API adapters and activity card, under synthetic read-only responses"
],
"viewports": [
{
@@ -30,23 +31,59 @@
}
],
"states": [
- "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
- "Denied filtered facets do not fall back to the unfiltered bundle",
- "Retry retains denial while pending; fresh successful response restores events and clears guidance",
- "First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
- "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
- "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
+ "Initial attempt and held-event reads pending without an empty claim",
+ "Successful attempts do not wait for held events; refresh remains usable",
+ "Both completed empty reads establish the empty window",
+ "Independent readable attempts and held-event rows",
+ "Held-event HTTP403 clears its rows and warns, without hiding attempt evidence",
+ "Pending held retry retains the warning until success",
+ "Attempt HTTP503 clears only attempt evidence, preserving readable held rows",
+ "Both failures warn without empty claims; successful reads clear warnings"
],
"interactions": [
- "Remount full production resource drawer with denied/transient reads",
- "Activate scoped Retry with keyboard Enter",
- "Apply and clear Change kind filter",
- "Switch between Overview and Manage"
+ "Desktop keyboard Enter and phone touch refresh through the real existing Notifications tab",
+ "Partial failure, pending retry and recovery via synthetic API replies; no mutation"
],
- "limitations": [
- "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
- "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
- "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
- "Independent reader authorisation remains independent; this is not a global session revocation broadcast."
+ "browsers": [
+ {
+ "engine": "desktop",
+ "version": "141.0.7390.37"
+ },
+ {
+ "engine": "phone",
+ "version": "26.0"
+ }
+ ],
+ "playwright_version": "1.56.1",
+ "checks": 20,
+ "screenshots": [
+ {
+ "path": "tmp/notification-evidence-proof/desktop-held-pending.png",
+ "sha256": "58ddaf1dae1e797e6f938c3e74b705fe75276eaf560de307fe7bfe1644c2df21"
+ },
+ {
+ "path": "tmp/notification-evidence-proof/desktop-held-unavailable.png",
+ "sha256": "fe343a2b7eb67ae1283e4eb411462073e3db26d36d226ef62d9fd49cf9a095c7"
+ },
+ {
+ "path": "tmp/notification-evidence-proof/desktop-attempts-unavailable.png",
+ "sha256": "a4ae26cc87b0bbf91b4dc3150f375a10c6701201deed4eaa3bdfbfce08ade4de"
+ },
+ {
+ "path": "tmp/notification-evidence-proof/phone-held-pending.png",
+ "sha256": "66262c32b745e68f32aba48db19dee2a03c7c62d763efd765ed4da154ec9e00f"
+ },
+ {
+ "path": "tmp/notification-evidence-proof/phone-held-unavailable.png",
+ "sha256": "534cd02e8b5392bd5e73d7f4a7a3d8e2a78596a230d055daef4479ecf0d1fdbc"
+ },
+ {
+ "path": "tmp/notification-evidence-proof/phone-attempts-unavailable.png",
+ "sha256": "09652b8023be5e527712d42384e9fee8e114575026aefaf703e62cfa30bc0884"
+ }
+ ],
+ "proof_script": "frontend-modern/browser-tests/notification-evidence.cjs",
+ "limits": [
+ "Synthetic source/UI evidence only, not notification receipt, native provider delivery, release availability or dependency advisory clearance"
]
}
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx
index 8291e514a0..2f03d28dcb 100644
--- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx
+++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx
@@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
import { formatInteger } from './resourceDetailMappers';
+import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
interface ResourceSummaryPresentationProps {
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component {
).toBe('https://prod.k8s.local:6443');
});
});
+
+describe('buildKubernetesControllerSection', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ const absolute = (timestamp: string): string =>
+ formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
+
+ it('returns nothing for resources that are not workload controllers', () => {
+ expect(
+ buildKubernetesControllerSection(
+ makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }),
+ ),
+ ).toBeNull();
+ expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull();
+ expect(
+ buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })),
+ ).toBeNull();
+ });
+
+ it('leads a Job with its absolute start and completion times, then the counts', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
+
+ const section = buildKubernetesControllerSection(
+ makeResource({
+ id: 'nightly-import',
+ type: 'k8s-job',
+ kubernetes: {
+ clusterName: 'prod',
+ namespace: 'batch',
+ desiredReplicas: 10,
+ active: 1,
+ succeeded: 8,
+ failed: 2,
+ startTime: '2026-05-24T12:55:00Z',
+ completionTime: '2026-05-24T13:00:00Z',
+ },
+ }),
+ );
+
+ expect(section?.label).toBe('Job');
+ expect(section?.testId).toBe('resource-kubernetes-controller-section');
+ expect(section?.rows).toEqual([
+ { label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true },
+ { label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true },
+ { label: 'Duration', value: '5m' },
+ { label: 'Target', value: '10 completions' },
+ { label: 'Active', value: '1' },
+ { label: 'Succeeded', value: '8' },
+ { label: 'Failed', value: '2', tone: 'danger' },
+ { label: 'Namespace', value: 'batch' },
+ { label: 'Cluster', value: 'prod' },
+ ]);
+ });
+
+ it('keeps a running Job on its start time without a completion or duration row', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
+
+ const section = buildKubernetesControllerSection(
+ makeResource({
+ id: 'nightly-import',
+ type: 'k8s-job',
+ kubernetes: {
+ namespace: 'batch',
+ desiredReplicas: 1,
+ active: 1,
+ failed: 0,
+ startTime: '2026-05-24T13:29:30Z',
+ },
+ }),
+ );
+
+ expect(section?.rows.map((row) => row.label)).toEqual([
+ 'Started',
+ 'Target',
+ 'Active',
+ 'Failed',
+ 'Namespace',
+ ]);
+ expect(section?.rows[0]).toEqual({
+ label: 'Started',
+ value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`,
+ wrap: true,
+ });
+ expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' });
+ });
+
+ it('leads a CronJob with its schedule and the last run and last success times', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
+
+ const section = buildKubernetesControllerSection(
+ makeResource({
+ id: 'billing-rollup',
+ type: 'k8s-cronjob',
+ kubernetes: {
+ clusterName: 'prod',
+ namespace: 'batch',
+ schedule: '0 2 * * *',
+ suspend: true,
+ active: 0,
+ lastScheduleTime: '2026-05-24T02:00:00Z',
+ lastSuccessfulTime: '2026-05-23T02:00:00Z',
+ },
+ }),
+ );
+
+ expect(section?.label).toBe('CronJob');
+ expect(section?.rows).toEqual([
+ { label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' },
+ { label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true },
+ { label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
+ { label: 'Suspended', value: 'Yes', tone: 'warning' },
+ { label: 'Active', value: '0' },
+ { label: 'Namespace', value: 'batch' },
+ { label: 'Cluster', value: 'prod' },
+ ]);
+ });
+
+ it('carries the service name and counts for StatefulSets and DaemonSets', () => {
+ const statefulSet = buildKubernetesControllerSection(
+ makeResource({
+ id: 'checkout-api-stateful',
+ type: 'k8s-statefulset',
+ kubernetes: {
+ namespace: 'apps',
+ desiredReplicas: 3,
+ currentReplicas: 3,
+ readyReplicas: 2,
+ availableReplicas: 2,
+ updatedReplicas: 2,
+ serviceName: 'checkout-headless',
+ },
+ }),
+ );
+ expect(statefulSet?.label).toBe('StatefulSet');
+ expect(statefulSet?.rows).toEqual([
+ { label: 'Service', value: 'checkout-headless' },
+ { label: 'Target', value: '3 pods' },
+ { label: 'Current', value: '3' },
+ { label: 'Ready', value: '2' },
+ { label: 'Available', value: '2' },
+ { label: 'Updated', value: '2' },
+ { label: 'Namespace', value: 'apps' },
+ ]);
+
+ const daemonSet = buildKubernetesControllerSection(
+ makeResource({
+ id: 'node-exporter',
+ type: 'k8s-daemonset',
+ kubernetes: {
+ namespace: 'observability',
+ desiredNumberScheduled: 6,
+ currentNumberScheduled: 6,
+ numberReady: 5,
+ numberAvailable: 5,
+ numberUnavailable: 1,
+ numberMisscheduled: 0,
+ updatedReplicas: 5,
+ },
+ }),
+ );
+ expect(daemonSet?.label).toBe('DaemonSet');
+ expect(daemonSet?.rows).toEqual([
+ { label: 'Target', value: '6 nodes' },
+ { label: 'Current', value: '6' },
+ { label: 'Ready', value: '5' },
+ { label: 'Available', value: '5' },
+ { label: 'Updated', value: '5' },
+ { label: 'Unavailable', value: '1', tone: 'warning' },
+ { label: 'Misscheduled', value: '0', tone: 'default' },
+ { label: 'Namespace', value: 'observability' },
+ ]);
+ });
+
+ it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => {
+ const section = buildKubernetesControllerSection(
+ makeResource({
+ id: 'checkout-api-replicaset',
+ type: 'k8s-replicaset',
+ kubernetes: {
+ desiredReplicas: 4,
+ readyReplicas: 3,
+ fullyLabeledReplicas: 4,
+ observedGeneration: 7,
+ },
+ }),
+ );
+ expect(section?.label).toBe('ReplicaSet');
+ expect(section?.rows).toEqual([
+ { label: 'Target', value: '4 pods' },
+ { label: 'Ready', value: '3' },
+ { label: 'Fully labeled', value: '4' },
+ { label: 'Observed generation', value: '7' },
+ ]);
+ });
+});
diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts
index 5d3d5e9275..2d44225f6b 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts
@@ -3,9 +3,18 @@ import {
compactDetailRows as compactRows,
compactDetailSections as compactSections,
formatDetailBytesValue,
+ formatDetailCountValue,
+ formatDetailIntegerValue,
makeDetailRow as makeRow,
+ type DetailRow,
type DetailSection,
+ type DetailValueTone,
} from '@/components/shared/detailSectionModel';
+import {
+ formatPlatformTableDateTimeValue,
+ formatPlatformTableDurationValue,
+ formatPlatformTableRelativeTimeValue,
+} from '@/features/platformPage/sharedPlatformPage';
export type ResourceDetailDrawerKubernetesSection = DetailSection;
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
return compactSections(sections);
};
+const countRow = (
+ label: string,
+ value: number | undefined,
+ options: { noun?: string; tone?: (count: number) => DetailValueTone } = {},
+): DetailRow | null => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
+ const text = options.noun
+ ? formatDetailCountValue(value, options.noun)
+ : formatDetailIntegerValue(value);
+ return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {});
+};
+
+const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
+const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
+
+// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
+// only has room for an age, so the expansion carries the absolute time first
+// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps
+// because a phone-width detail cell is too narrow for both on one line.
+const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => {
+ const raw = asString(timestamp);
+ if (!raw) return null;
+ const absolute = formatPlatformTableDateTimeValue(raw, {
+ emptyText: '',
+ dateTimeFormat: { year: 'numeric' },
+ });
+ if (!absolute) return makeRow(label, raw, { wrap: true });
+ const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' });
+ return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true });
+};
+
+const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => {
+ const startedAt = Date.parse(asString(start) ?? '');
+ const endedAt = Date.parse(asString(end) ?? '');
+ if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
+ return undefined;
+ }
+ return (endedAt - startedAt) / 1000;
+};
+
+type ControllerRows = { label: string; rows: Array };
+
+// Each kind leads with what its narrow table row drops: the Detail column
+// (service name, timestamps) below the large layout and Target on a phone.
+const controllerRows = (resource: Resource): ControllerRows | null => {
+ const k = resource.kubernetes;
+ if (!k) return null;
+ switch (resource.type) {
+ case 'k8s-replicaset':
+ return {
+ label: 'ReplicaSet',
+ rows: [
+ countRow('Target', k.desiredReplicas, { noun: 'pod' }),
+ countRow('Current', k.currentReplicas),
+ countRow('Ready', k.readyReplicas),
+ countRow('Available', k.availableReplicas),
+ countRow('Fully labeled', k.fullyLabeledReplicas),
+ countRow('Observed generation', k.observedGeneration),
+ ],
+ };
+ case 'k8s-statefulset':
+ return {
+ label: 'StatefulSet',
+ rows: [
+ makeRow('Service', k.serviceName),
+ countRow('Target', k.desiredReplicas, { noun: 'pod' }),
+ countRow('Current', k.currentReplicas),
+ countRow('Ready', k.readyReplicas),
+ countRow('Available', k.availableReplicas),
+ countRow('Updated', k.updatedReplicas),
+ ],
+ };
+ case 'k8s-daemonset':
+ return {
+ label: 'DaemonSet',
+ rows: [
+ countRow('Target', k.desiredNumberScheduled, { noun: 'node' }),
+ countRow('Current', k.currentNumberScheduled),
+ countRow('Ready', k.numberReady),
+ countRow('Available', k.numberAvailable),
+ countRow('Updated', k.updatedReplicas),
+ countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }),
+ countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }),
+ ],
+ };
+ case 'k8s-job':
+ return {
+ label: 'Job',
+ rows: [
+ timestampRow('Started', k.startTime),
+ timestampRow('Completed', k.completionTime),
+ makeRow(
+ 'Duration',
+ formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), {
+ emptyText: '',
+ }),
+ ),
+ countRow('Target', k.desiredReplicas, { noun: 'completion' }),
+ countRow('Active', k.active),
+ countRow('Succeeded', k.succeeded),
+ countRow('Failed', k.failed, { tone: dangerWhenPositive }),
+ ],
+ };
+ case 'k8s-cronjob':
+ return {
+ label: 'CronJob',
+ rows: [
+ makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }),
+ timestampRow('Last run', k.lastScheduleTime),
+ timestampRow('Last success', k.lastSuccessfulTime),
+ makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }),
+ countRow('Active', k.active),
+ ],
+ };
+ default:
+ return null;
+ }
+};
+
+// Workload-controller facts for the always-visible summary of the resource
+// drawer, the same slot Docker containers use for their timestamps. The
+// controllers table hides Scope and Detail below its large layout and Target
+// on a phone, so without this a Job's completion time or a CronJob's last
+// success is unreachable from a narrow row.
+export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => {
+ const controller = controllerRows(resource);
+ if (!controller) return null;
+ const rows = compactRows([
+ ...controller.rows,
+ makeRow('Namespace', resource.kubernetes?.namespace),
+ makeRow('Cluster', resource.kubernetes?.clusterName),
+ ]);
+ if (rows.length === 0) return null;
+ return {
+ label: controller.label,
+ rows,
+ testId: 'resource-kubernetes-controller-section',
+ };
+};
+
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
const k = resource.kubernetes;
if (!k) return null;
diff --git a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx
index b8dd29d332..4fee194fd4 100644
--- a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx
+++ b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx
@@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
+import { getShippedDocUrl } from '@/utils/docsLinks';
const HISTORY_LIMIT = 20;
@@ -45,7 +46,7 @@ const statusPresentation = (status: string): { label: string; className: string
}
};
-// A rollback restores the backup taken before this update was applied, so it
+// A rollback uses the backup taken before this update was applied, so it
// is only offered where that backup is still retained on disk (the backend
// clears backup_path when retention prunes it) and the update actually landed.
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
@@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => {
Update History
- Updates applied through Pulse, with rollback to the backup taken before each one.
+ Updates applied through Pulse, with version rollback using a retained backup.
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
-
- This restores the binary, configuration, and data from the backup taken before the
- update to {formatVersionLabel(entry().version_to)} on{' '}
- {formatTimestamp(entry().timestamp)}.
+
+ Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
- Settings and alert changes made since that backup will be reverted, and Pulse will
- restart to complete the rollback.
+ This requests an installation rollback using the backup taken before the update to{' '}
+ {formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}.
+ Pulse will restart if the rollback succeeds.
+
+
+ Restore scope is not reported for this backup. A scope-aware server keeps active
+ runtime data in place and restores only the binary and independent installation
+ files. Older recovery can also replace install-local data and configuration,
+ reverting later changes.
+
+
+ Do not assume this will undo settings or alerts. Full-state recovery requires
+ Pulse to be stopped and a consistent backup of every active store.{' '}
+
+ Read the recovery instructions
+
+ .
diff --git a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
index 4f6ab77192..20a5b255e5 100644
--- a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
+++ b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx
@@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates';
const mockListUpdateHistory = vi.fn();
const mockStoreRollbackUpdate = vi.fn();
+let runningVersion: string | undefined = '6.0.5';
vi.mock('@/api/updates', () => ({
UpdatesAPI: {
@@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({
vi.mock('@/stores/updates', () => ({
updateStore: {
- versionInfo: () => ({ version: '6.0.5' }),
+ versionInfo: () => (runningVersion ? { version: runningVersion } : null),
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
},
}));
@@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => {
beforeEach(() => {
mockListUpdateHistory.mockReset();
mockStoreRollbackUpdate.mockReset();
+ runningVersion = '6.0.5';
});
afterEach(() => {
@@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
+ expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument();
+ expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent(
+ 'A scope-aware server keeps active runtime data in place',
+ );
+ expect(screen.getByText(/Older recovery can also/)).toHaveTextContent(
+ 'reverting later changes',
+ );
+ expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent(
+ 'Full-state recovery requires Pulse to be stopped',
+ );
+ expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute(
+ 'href',
+ '/docs/AUTO_UPDATE#manual-rollback',
+ );
+ expect(
+ screen.queryByText(/Settings and alert changes made since that backup will be reverted/),
+ ).not.toBeInTheDocument();
+ expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
@@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => {
expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(),
);
});
+
+ it('distinguishes the running version from an older history entry without guessing restore scope', async () => {
+ runningVersion = 'v6.6.0-rc.1';
+ mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]);
+ render(() =>
);
+ fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
+ expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument();
+ expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent(
+ 'before the update to v6.0.5',
+ );
+ expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
+ expect(screen.queryByText('restore everything')).not.toBeInTheDocument();
+ expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
+ });
+
+ it('does not infer the running version from the selected update when version information is missing', async () => {
+ runningVersion = undefined;
+ mockListUpdateHistory.mockResolvedValue([baseEntry]);
+ render(() =>
);
+ fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
+ expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument();
+ expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
+ });
+
+ it('retains the consent and refreshes history after a rejected rollback', async () => {
+ mockListUpdateHistory.mockResolvedValue([baseEntry]);
+ mockStoreRollbackUpdate.mockResolvedValue(false);
+ render(() =>
);
+ fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
+ await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2));
+ expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported');
+ expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled();
+ });
+
+ it('prevents duplicate requests and cancellation while the rollback request is pending', async () => {
+ mockListUpdateHistory.mockResolvedValue([baseEntry]);
+ let finish!: (accepted: boolean) => void;
+ mockStoreRollbackUpdate.mockReturnValue(
+ new Promise
((resolve) => {
+ finish = resolve;
+ }),
+ );
+ render(() => );
+ fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
+ expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' }));
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ finish(true);
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
});
diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx
index 71c6cedb3a..3588836cc0 100644
--- a/frontend-modern/src/components/Storage/DiskDetail.tsx
+++ b/frontend-modern/src/components/Storage/DiskDetail.tsx
@@ -11,6 +11,10 @@ import {
resolveHistoryRangeWithinLimit,
} from '@/components/Storage/historyRangeAccess';
import {
+ DISK_DETAIL_HEADER_STACK_CLASS,
+ DISK_DETAIL_HEALTH_LABEL_CLASS,
+ DISK_DETAIL_HEALTH_ROW_CLASS,
+ DISK_DETAIL_HEALTH_SUMMARY_CLASS,
DISK_DETAIL_HISTORY_RANGE_OPTIONS,
DISK_DETAIL_LIVE_CHARTS,
getDiskAttributeValueTextClass,
@@ -55,6 +59,7 @@ export const DiskDetail: Component = (props) => {
historyResourceId,
attributeCards,
historyCharts,
+ healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -91,19 +96,32 @@ export const DiskDetail: Component = (props) => {
return (
diff --git a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx
index 2c07536cad..2d3589247e 100644
--- a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx
@@ -259,7 +259,7 @@ describe('DestinationsTab recovery while editing SMTP', () => {
).toBeInTheDocument();
expect(
await screen.findByText(
- 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.',
+ 'Pulse could not read the delivery log. Refresh to check delivery attempts.',
),
).toBeInTheDocument();
expect(screen.queryByText('SMTP fixture rejected')).not.toBeInTheDocument();
diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
index b01d8e43b0..2515c6f393 100644
--- a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx
@@ -156,6 +156,17 @@ describe('useAlertDestinationsTabState', () => {
await Promise.resolve();
expect(result.deliveryLog()).toEqual(current);
expect(result.deliveryLogUnavailable()).toBe(false);
+ expect(result.heldEventsUnavailable()).toBe(false);
+ expect(result.refreshingHeldEvents()).toBe(false);
+ vi.mocked(AlertsAPI.getEvents).mockRejectedValueOnce(new Error('held events denied'));
+ vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValueOnce(current);
+ await result.loadDeliveryLog();
+ expect(result.heldEvents()).toEqual([]);
+ expect(result.heldEventsUnavailable()).toBe(true);
+ expect(result.refreshingHeldEvents()).toBe(false);
+ expect(result.deliveryLogUnavailable()).toBe(false);
+ expect(result.deliveryLog()).toEqual(current);
+
expect(result.refreshingDeliveryLog()).toBe(false);
} finally {
cleanup();
diff --git a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx
index e4398987af..ad497217a0 100644
--- a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx
@@ -107,6 +107,8 @@ describe('useNotificationDeliveryLog', () => {
expect(state.deliveryLog()).toEqual(emptyLog);
expect(state.refreshingDeliveryLog()).toBe(false);
expect(state.heldEvents()).toEqual([]);
+ expect(state.refreshingHeldEvents()).toBe(true);
+ expect(state.heldEventsUnavailable()).toBe(false);
} finally {
held.resolve([]);
dispose();
@@ -132,6 +134,8 @@ describe('useNotificationDeliveryLog', () => {
expect(state.heldEvents()).toHaveLength(1);
await state.loadDeliveryLog();
expect(state.heldEvents()).toEqual([]);
+ expect(state.heldEventsUnavailable()).toBe(true);
+ expect(state.refreshingHeldEvents()).toBe(false);
expect(state.deliveryLog()).toEqual(emptyLog);
expect(state.deliveryLogUnavailable()).toBe(false);
expect(state.refreshingDeliveryLog()).toBe(false);
@@ -222,6 +226,8 @@ describe('useNotificationDeliveryLog', () => {
else older.reject(new Error('stale held read'));
await Promise.resolve();
expect(state.heldEvents()).toEqual(current);
+ expect(state.heldEventsUnavailable()).toBe(false);
+ expect(state.refreshingHeldEvents()).toBe(false);
} finally {
dispose();
}
@@ -264,4 +270,56 @@ describe('useNotificationDeliveryLog', () => {
expect(AlertsAPI.getEvents).toHaveBeenCalledTimes(1);
}),
);
+ it('retains the held-read warning during retry until that read succeeds', () =>
+ createRoot(async (dispose) => {
+ const recovery = deferred
>>();
+ vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
+ vi.mocked(AlertsAPI.getEvents)
+ .mockRejectedValueOnce(new Error('permission denied'))
+ .mockReturnValueOnce(recovery.promise);
+ const state = useNotificationDeliveryLog();
+ try {
+ await state.loadDeliveryLog();
+ expect(state.heldEventsUnavailable()).toBe(true);
+ await state.loadDeliveryLog();
+ expect(state.heldEventsUnavailable()).toBe(true);
+ expect(state.refreshingHeldEvents()).toBe(true);
+ expect(state.refreshingDeliveryLog()).toBe(false);
+ recovery.resolve([]);
+ await recovery.promise;
+ expect(state.heldEventsUnavailable()).toBe(false);
+ expect(state.refreshingHeldEvents()).toBe(false);
+ } finally {
+ dispose();
+ }
+ }));
+
+ it.each(['success', 'failure'])(
+ 'keeps the newest held read pending after an older %s',
+ (outcome) =>
+ createRoot(async (dispose) => {
+ const older = deferred>>();
+ const newer = deferred>>();
+ vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog);
+ vi.mocked(AlertsAPI.getEvents)
+ .mockReturnValueOnce(older.promise)
+ .mockReturnValueOnce(newer.promise);
+ const state = useNotificationDeliveryLog();
+ try {
+ await state.loadDeliveryLog();
+ await state.loadDeliveryLog();
+ if (outcome === 'success') older.resolve([]);
+ else older.reject(new Error('stale denied request'));
+ await Promise.resolve();
+ expect(state.refreshingHeldEvents()).toBe(true);
+ expect(state.heldEventsUnavailable()).toBe(false);
+ newer.resolve([]);
+ await newer.promise;
+ expect(state.refreshingHeldEvents()).toBe(false);
+ expect(state.heldEventsUnavailable()).toBe(false);
+ } finally {
+ dispose();
+ }
+ }),
+ );
});
diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
index 3f2e0f7718..d6462cefe9 100644
--- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
+++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx
@@ -102,6 +102,8 @@ export function DestinationsTab(props: DestinationsTabProps) {
onRefresh={() => void state.loadDeliveryLog()}
webhooks={state.webhooks()}
heldEvents={state.heldEvents()}
+ heldEventsUnavailable={state.heldEventsUnavailable()}
+ refreshingHeldEvents={state.refreshingHeldEvents()}
/>
diff --git a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
index 26d815d52c..86c90f37f1 100644
--- a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
+++ b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts
@@ -53,6 +53,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLogUnavailable,
refreshingDeliveryLog,
heldEvents,
+ heldEventsUnavailable,
+ refreshingHeldEvents,
loadDeliveryLog,
} = useNotificationDeliveryLog();
const webhookState = useAlertWebhookDestinationsState({
@@ -173,6 +175,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro
deliveryLogUnavailable,
deliveryNeedsAttention,
heldEvents,
+ heldEventsUnavailable,
+ refreshingHeldEvents,
dismissTerminalFailures,
dismissingTerminalFailures,
handleRetry,
diff --git a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts
index 1ddceb830f..e36c6036df 100644
--- a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts
+++ b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts
@@ -19,12 +19,15 @@ const DELIVERY_LOG_LIMIT = 200;
// Held events are the third half of that story: notifications that were
// deliberately not attempted, with the mechanism that held them. They come
// from the alert event log and degrade independently — a failed events read
-// hides the held rows without marking delivery attempts unavailable.
+// withdraws the held rows and reports that gap without hiding readable attempts.
export function useNotificationDeliveryLog() {
const [deliveryLog, setDeliveryLog] = createSignal(null);
const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false);
const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false);
const [heldEvents, setHeldEvents] = createSignal([]);
+ const [heldEventsUnavailable, setHeldEventsUnavailable] = createSignal(false);
+ // Before the first read, an empty array means unknown, not no held alerts.
+ const [refreshingHeldEvents, setRefreshingHeldEvents] = createSignal(true);
// Both reads belong to the same refresh, but held events never block the log.
let latestRequest = 0;
@@ -35,6 +38,7 @@ export function useNotificationDeliveryLog() {
const ownsRequest = (request: number) => !disposed && request === latestRequest;
const loadHeldEvents = async (request: number) => {
+ setRefreshingHeldEvents(true);
try {
const since = new Date(
Date.now() - HELD_EVENT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
@@ -44,11 +48,16 @@ export function useNotificationDeliveryLog() {
since,
limit: HELD_EVENT_LIMIT,
});
- if (ownsRequest(request)) setHeldEvents(events);
+ if (!ownsRequest(request)) return;
+ setHeldEvents(events);
+ setHeldEventsUnavailable(false);
} catch (error) {
if (!ownsRequest(request)) return;
logger.error('Failed to load held alert notification events', error);
setHeldEvents([]);
+ setHeldEventsUnavailable(true);
+ } finally {
+ if (ownsRequest(request)) setRefreshingHeldEvents(false);
}
};
@@ -82,6 +91,8 @@ export function useNotificationDeliveryLog() {
deliveryLogUnavailable,
refreshingDeliveryLog,
heldEvents,
+ heldEventsUnavailable,
+ refreshingHeldEvents,
loadDeliveryLog,
};
}
diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx
index 88345be440..744bc18b71 100644
--- a/frontend-modern/src/features/docker/DockerImagesTable.tsx
+++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx
@@ -152,7 +152,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="host"
- class="platform-table-mobile-w-15 md:w-[18%]"
+ class="platform-table-phone-hidden md:w-[18%]"
>
Host
@@ -160,7 +160,7 @@ export const DockerImagesTable: Component<
kind="text"
sort={sort}
sortKey="usedBy"
- class="platform-table-mobile-w-15 md:w-[24%]"
+ class="platform-table-phone-hidden md:w-[24%]"
>
Used by
@@ -168,7 +168,7 @@ export const DockerImagesTable: Component<
kind="numeric-value"
sort={sort}
sortKey="size"
- class="platform-table-mobile-w-15 md:w-[12%]"
+ class="platform-table-mobile-w-20 md:w-[12%]"
>
Size
@@ -176,7 +176,7 @@ export const DockerImagesTable: Component<
kind="badge"
sort={sort}
sortKey="update"
- class="platform-table-mobile-w-15 md:w-[16%]"
+ class="platform-table-mobile-w-25 md:w-[16%]"
>
@@ -211,13 +211,15 @@ export const DockerImagesTable: Component<
/>
}
/>
+ {/* Host and consumers fit the row expansion on a phone; the
+ row keeps the image, its size and the update verdict. */}
{dockerHostName(resource)}
c.toUpperCase())
+ : 'Updating';
const title = [state, message, completedAt].filter(Boolean).join(' | ') || label;
return { label, title };
};
@@ -256,7 +262,7 @@ export const DockerServicesTable: Component<{
sortKey="running"
class="platform-table-mobile-w-10 w-[10%] md:w-[8%]"
>
- Running
+
{update().label}
diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx
index d5f01c409a..50a7e7e929 100644
--- a/frontend-modern/src/features/docker/DockerTasksTable.tsx
+++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx
@@ -12,6 +12,7 @@ import {
createPlatformTableSortState,
getPlatformTableCellClassForKind,
getPlatformTableDateTimeSortValue,
+ PlatformTableRelativeTimeValue,
PlatformTableShell,
type PlatformTableSortValue,
withPlatformStatusCounts,
@@ -255,11 +256,10 @@ export const DockerTasksTable: Component = (props) => {
-
- {dockerTextValue(resource.docker?.startedAt)}
+ {/* Swarm reports a raw RFC3339 stamp with microseconds; an age
+ reads at a glance and the stamp stays in the title. */}
+
+
diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
index 1695ae6c76..0e34de84ec 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
@@ -1505,6 +1505,8 @@ describe('Docker native tables', () => {
));
expect(screen.getByText('Update')).toBeInTheDocument();
+ expect(screen.getByText('Run')).toHaveClass('platform-table-label-compact');
+ expect(screen.getByText('Running')).toHaveClass('platform-table-label-full');
expect(screen.getByText('Stack')).toBeInTheDocument();
expect(screen.getByText('shop')).toBeInTheDocument();
expect(screen.getByText('checkout-api')).toBeInTheDocument();
@@ -1512,7 +1514,12 @@ describe('Docker native tables', () => {
expect(screen.getByText('replicated')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
- expect(screen.getByText('rollback_started')).toBeInTheDocument();
+ // The snake_case rollout token reads as words; the raw token stays in the title.
+ expect(screen.getByText('Rollback started')).toBeInTheDocument();
+ expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'break-words');
+ expect(
+ screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
+ ).toContain('rollback_started');
expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument();
expect(screen.getByText('manager-1')).toBeInTheDocument();
expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull();
@@ -1651,7 +1658,10 @@ describe('Docker native tables', () => {
expect(screen.getByText('web.2')).toBeInTheDocument();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('running 2 minutes')).toBeInTheDocument();
- expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument();
+ // The raw RFC3339 stamp moves to the title; the cell shows an age.
+ const started = screen.getByTitle('2026-05-24T13:05:00Z');
+ expect(started).toBeInTheDocument();
+ expect(started.textContent).toMatch(/ago$/);
});
it('renders Docker Swarm secret API metadata without secret data', () => {
diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
index f8a8fc36f4..41f10a0869 100644
--- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
+++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts
@@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => {
const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true);
const ids = columns.map((column) => column.id);
- expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']);
+ // Restarts waits for mobile width: a phone row could not fit its header
+ // beside the Update control, and the count stays in the row expansion.
+ expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({
width: '30%',
});
diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts
index b573f3aba2..2961092c89 100644
--- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts
+++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts
@@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
state: 'narrow',
cpu: 'narrow',
memory: 'narrow',
- restarts: 'phone',
+ // A phone row cannot fit the Restarts header beside the Update control;
+ // the count stays in the row expansion there and returns at mobile width.
+ restarts: 'mobile',
updates: 'narrow',
actions: 'mobile',
host: 'tablet',
@@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
},
phone: {
container: 32,
- state: 13,
- cpu: 15,
- memory: 17,
- restarts: 10,
- updates: 13,
+ state: 14,
+ cpu: 14,
+ memory: 14,
+ updates: 16,
},
mobile: {
container: 30,
@@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
updates: 9,
actions: 6,
},
+ // Compact is what a 1280-1536px laptop window gets. Host, Engine and the
+ // Update control are short values that were clipped ("docker 2…") while the
+ // percentage-only CPU and memory bars had room to spare.
compact: {
container: 18,
- host: 10,
- runtime: 8,
- image: 18,
+ host: 12,
+ runtime: 9.5,
+ image: 16.5,
state: 7,
- cpu: 11,
- memory: 12,
- restarts: 9,
+ cpu: 9.5,
+ memory: 10,
+ restarts: 8.5,
ports: 12,
- updates: 9,
+ updates: 10,
actions: 8,
},
};
diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
index 8d7c18e9d3..3b64504700 100644
--- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
+++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx
@@ -16,6 +16,7 @@ import {
formatPlatformTableDateTimeValue,
formatPlatformTableRelativeTimeValue,
formatPlatformTableTextValue,
+ getPlatformResourceCountNoun,
getPlatformTableCellClassForKind,
PlatformTableShell,
type PlatformTableSortValue,
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
const controllerKind = (resource: Resource): string =>
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
+const countedTarget = (count: number | undefined, noun: string): string => {
+ const value = count ?? 0;
+ return `${value} ${getPlatformResourceCountNoun(noun, value)}`;
+};
+
const targetValue = (resource: Resource): string => {
switch (resource.type) {
case 'k8s-replicaset':
case 'k8s-statefulset':
- return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
+ return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
case 'k8s-daemonset':
- return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
+ return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
case 'k8s-job':
- return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
+ return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
case 'k8s-cronjob':
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
default:
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="kind"
- class="platform-table-mobile-w-15 md:w-[8%]"
+ class="platform-table-mobile-w-25 md:w-[8%]"
>
Kind
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
>
Scope
+ {/* The phone row keeps controller, kind, ready and issues. A
+ 15% kind track clipped "DaemonSet" and "StatefulSet" and the
+ target track clipped "1 completions", so target moves to the
+ row expansion, which also carries the schedule and job
+ timestamps the narrower layouts drop. */}
Target
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
kind="text"
sort={sort}
sortKey="exceptions"
- class="platform-table-mobile-w-20 md:w-[11%]"
+ class="platform-table-mobile-w-30 md:w-[11%]"
>
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
{target()}
diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx
index cf201f66f5..1823b37427 100644
--- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx
+++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx
@@ -1,6 +1,16 @@
-import { cleanup, render, screen } from '@solidjs/testing-library';
+import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
+vi.mock('@/api/resources', () => ({
+ ResourceAPI: {
+ getFacetBundle: vi.fn().mockResolvedValue({
+ capabilities: [],
+ relationships: [],
+ recentChanges: [],
+ }),
+ },
+}));
+
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import { KubernetesControllersTable } from '../KubernetesControllersTable';
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
});
+ it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => {
+ render(() => (
+ }
+ emptyTitle="No controllers"
+ emptyDescription="No controllers"
+ showToolbar={false}
+ />
+ ));
+
+ // "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and
+ // "1 completions" clipped in the target track, so the phone row widens kind
+ // and issues and demotes target to the row expansion.
+ expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25');
+ expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden');
+ expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden');
+ expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30');
+ expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30');
+ expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15');
+ expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden');
+ });
+
+ it('shows the absolute Job and CronJob times in the row expansion', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
+
+ render(() => (
+ }
+ emptyTitle="No controllers"
+ emptyDescription="No controllers"
+ showToolbar={false}
+ />
+ ));
+
+ // Tapping the row opens the expansion; the Detail column is hidden below a
+ // large container, so this is where a phone or half-width pane reads the
+ // completion time.
+ fireEvent.click(screen.getByText('nightly-import'));
+ const job = screen.getByTestId('resource-kubernetes-controller-section');
+ expect(within(job).getByText('Started')).toBeInTheDocument();
+ expect(
+ within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
+ ).toBeInTheDocument();
+ expect(within(job).getByText('Completed')).toBeInTheDocument();
+ expect(
+ within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`),
+ ).toBeInTheDocument();
+ expect(within(job).getByText('Duration')).toBeInTheDocument();
+ expect(within(job).getByText('5m')).toBeInTheDocument();
+ expect(within(job).getByText('Target')).toBeInTheDocument();
+ expect(within(job).getByText('10 completions')).toBeInTheDocument();
+ expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2');
+
+ fireEvent.click(screen.getByText('billing-rollup'));
+ const cron = screen.getByTestId('resource-kubernetes-controller-section');
+ expect(within(cron).getByText('Schedule')).toBeInTheDocument();
+ expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument();
+ expect(within(cron).getByText('Last run')).toBeInTheDocument();
+ expect(
+ within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`),
+ ).toBeInTheDocument();
+ expect(within(cron).getByText('Last success')).toBeInTheDocument();
+ expect(
+ within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
+ ).toBeInTheDocument();
+ expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch');
+ });
+
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
@@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => {
/>
));
+ // A single desired completion reads as "1 completion", matching the
+ // expansion's Target row.
+ expect(screen.getByText('1 completion')).toBeInTheDocument();
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
'title',
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
index 2d6a7f9cf7..4bc6ed695f 100644
--- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
+++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts
@@ -322,7 +322,7 @@ describe('platform overview layout guardrails', () => {
expect(source).toContain('platform-table-mobile-w-30');
}
expect(dockerImagesTableSource).toMatch(
- /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/,
+ /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
);
expect(dockerServicesTableSource).toMatch(
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
index 313270eca0..79c59775f0 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
}
header={
<>
-
+
-
+
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
-
+
-
+
Used
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
>
{(datastore) => (
-
+
+
+
diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
index dfdf028f97..530547b651 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
@@ -43,6 +43,7 @@ import {
import {
buildProxmoxBackupRecoveryModel,
coverageRowMatchesSearch,
+ isBackupArtifact,
recoverableArtifactMatchesSearch,
type RecoverableArtifact,
type WorkloadCoverageRow,
@@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{
return recoveryModel().coverageRows.flatMap((row) => {
const artifacts = row.artifacts.filter(locationMatches);
if (artifacts.length === 0) return [];
+ // Same rule as the model: running or failed artifacts stay listed but
+ // never become a "latest" pointer, and snapshots never become the backup.
+ const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
return [
{
...row,
artifacts,
- latestRecovery: newestArtifact(artifacts),
- latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
+ latestBackup: newestArtifact(completed.filter(isBackupArtifact)),
+ latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')),
latestArchive: newestArtifact(
- artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
+ completed.filter((artifact) => artifact.sourceKind === 'archive'),
),
latestSnapshot: newestArtifact(
- artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
+ completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
),
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
@@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{
case 'workload':
return cmpString(a.workload.label, b.workload.label, direction);
case 'latest':
- return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction);
+ return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
case 'pbs':
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
case 'archive':
diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
index c963a6e98d..5c58e4c8f7 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx
@@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing
// "None" instead of restating the source ("No PVE backup file"). The long form
// truncated at most widths and stays available as the hover title.
const COVERAGE_EMPTY_LABEL = 'None';
+const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file';
+// Matches the posture engine's own wording for a snapshot-only subject.
+const COVERAGE_SNAPSHOT_ONLY_TITLE =
+ 'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.';
const coveragePostureVariant = (
posture: WorkloadCoverageRow['posture'],
@@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: {
headClass={getPlatformTableHeadClassForKind('text')}
/>
+
{COVERAGE_EMPTY_LABEL}
}
>
- {(artifact) => }
+ {(artifact) => (
+
+ )}
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
}
>
- {(artifact) => }
+ {(artifact) => (
+
+ )}
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
>
{artifact.detail || '—'}
diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx
index 6da1dd7948..4c29fd190c 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx
@@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: {
artifact.workload.name || artifact.workload.label,
artifact.workload.typeLabel,
artifact.workload.vmid,
+ // The phone projection has no location column, so the row's
+ // hover title carries the repository instead.
+ columnVisible('location') ? undefined : artifact.location,
]
.filter(Boolean)
.join(' · ')}
@@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: {
-
+
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
-
+
{artifact.detail || '—'}
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx
index 50adeb3dd8..b9b7e75eaf 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx
@@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(headers).toContain('Target ID');
expect(headers).toContain('Node');
expect(headers).toContain('Posture▲');
- expect(headers).toContain('Restore');
+ expect(headers).toContain('Last backup');
expect(headers).toContain('PBS snapshot');
expect(headers).toContain('Guest snapshot');
expect(headers).not.toContain('PVE file');
@@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
// cannot truncate to "No PVE backup f…".
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
for (const title of [
- 'No restore point',
+ 'No PBS snapshot or PVE backup file',
'No PBS snapshot',
'No PVE backup file',
'No guest snapshot',
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx
index 0dea167612..a8c0248ff5 100644
--- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx
@@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => {
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
'Workload',
'Via',
- 'Loc',
'Age▼',
'State',
]);
expect(document.body.textContent).toContain('VM 100');
- expect(document.body.textContent).toContain('main / pve1');
+ // The phone projection has no location column; the row title carries it.
+ expect(document.body.textContent).not.toContain('main / pve1');
+ expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull();
expect(document.body.textContent).not.toContain('5 PBS files');
});
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts
index 8ae3acda04..6ab7884a78 100644
--- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts
@@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
expect(model.coverageSummary.attention).toBe(0);
});
- it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => {
+ it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => {
const postures = new Map([
['vm-830', posture('vm-830', 'unprotected')],
['vm-840', posture('vm-840', 'protected')],
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts
index a7e222ac29..43fc7e5a41 100644
--- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts
@@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => {
);
});
+ it('shows the archive format as its detail and keeps the volid on hover and in search', () => {
+ const model = buildProxmoxBackupRecoveryModel({
+ workloads: [workload({})],
+ pbsBackups: [],
+ archives: [archive({ format: 'tar.zst' })],
+ snapshots: [],
+ tasks: [],
+ nowMs: Date.parse('2026-05-26T08:00:00Z'),
+ });
+
+ const [artifact] = model.recoverableArtifacts;
+ expect(artifact.detail).toBe('tar.zst');
+ expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
+ expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true);
+ });
+
+ it('falls back to the archive file name when the provider reports no format', () => {
+ const model = buildProxmoxBackupRecoveryModel({
+ workloads: [workload({})],
+ pbsBackups: [],
+ archives: [archive({ format: '' })],
+ snapshots: [],
+ tasks: [],
+ nowMs: Date.parse('2026-05-26T08:00:00Z'),
+ });
+
+ expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
+ });
+
it('keeps a terminally incomplete PBS artifact out of latest recovery', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
@@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => {
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
expect(failed).toMatchObject({ failed: true, running: false, verified: undefined });
- expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z');
+ expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z');
expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true);
});
+ it('keeps a fresh guest snapshot from standing in for the last backup', () => {
+ const model = buildProxmoxBackupRecoveryModel({
+ workloads: [workload({})],
+ pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })],
+ archives: [],
+ snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })],
+ tasks: [],
+ nowMs: Date.parse('2026-05-26T08:00:00Z'),
+ });
+
+ const row = model.coverageRows[0];
+ expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z');
+ expect(row.latestBackup?.sourceKind).toBe('pbs');
+ expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z');
+ expect(
+ getRecoveryAgeBand(
+ row.latestBackup?.createdMs,
+ model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'),
+ ),
+ ).toBe('aging');
+ });
+
+ it('reports no last backup for a snapshot-only workload', () => {
+ const model = buildProxmoxBackupRecoveryModel({
+ workloads: [workload({})],
+ pbsBackups: [],
+ archives: [],
+ snapshots: [snapshot()],
+ tasks: [],
+ nowMs: Date.parse('2026-05-26T08:00:00Z'),
+ });
+
+ const row = model.coverageRows[0];
+ expect(row.latestBackup).toBeUndefined();
+ expect(row.latestSnapshot?.sourceKind).toBe('snapshot');
+ expect(row.artifacts).toHaveLength(1);
+ expect(model.coverageSummary.recoverableArtifacts).toBe(1);
+ });
+
it('uses canonical workload attention while retaining the failed task as evidence', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts
index 8817787792..a52bd7c075 100644
--- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts
@@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => {
'status',
'datastore',
'used',
- 'backups',
]);
expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' });
- expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' });
+ expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' });
+ expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' });
expect(ids(getBackupServerColumns('basic'))).toEqual([
'server',
'status',
@@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => {
expect(ids(getRecoverableColumns('compact'))).toEqual([
'workload',
'source',
- 'location',
'created',
'state',
]);
expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' });
- expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' });
+ expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' });
expect(ids(getRecoverableColumns('basic'))).toEqual([
'workload',
'source',
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx
index cb34f14569..3694f78460 100644
--- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx
@@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra
import {
ArtifactSourceBadge,
ArtifactStateBadge,
+ ProxmoxBackupAgeText,
ProxmoxBackupWorkloadTypeBadge,
SortableHead,
+ formatCompactBackupAge,
} from '../proxmoxBackupsTableShared';
import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel';
@@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => {
}
});
+ it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => {
+ const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString();
+ expect(formatCompactBackupAge(createdAt)).toBe('18h');
+ expect(formatCompactBackupAge(new Date().toISOString())).toBe('now');
+
+ const artifact = {
+ id: 'pbs:1',
+ nativeId: '1',
+ sourceKind: 'pbs',
+ sourceLabel: 'PBS',
+ workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' },
+ createdAt,
+ createdMs: Date.parse(createdAt),
+ location: 'main',
+ detail: '',
+ protected: false,
+ } as RecoverableArtifact;
+ render(() => );
+ const age = screen.getByText('18h');
+ expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt);
+ });
+
it('keeps backup age text on the shared relative-time primitive', () => {
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts
index 1e23674fb7..305232fb26 100644
--- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts
+++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts
@@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => {
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
});
- it('shows the full operational metric track at phone width', () => {
+ it('matches the guest table columns and keeps node names whole at phone width', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
const ids = columns.map((column) => column.id);
- expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
+ expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']);
expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]');
- expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' });
- expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' });
- expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' });
- expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' });
+ expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' });
+ expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' });
+ expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' });
+ expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' });
});
it('prioritizes live utilization columns in the mobile host table', () => {
diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts
index c71846a6ae..b4288cf9f6 100644
--- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts
+++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts
@@ -47,6 +47,8 @@ export interface RecoverableArtifact {
/** Operator-facing source/repository label used by the backup-location filter. */
locationLabel?: string;
detail: string;
+ /** Full provider identifier behind a shortened `detail`, shown on hover. */
+ detailTitle?: string;
protected: boolean;
verified?: boolean;
/** A still-running backup is writing this artifact; it is not recoverable yet. */
@@ -70,7 +72,13 @@ export interface WorkloadCoverageRow {
key: string;
workload: WorkloadReference;
artifacts: RecoverableArtifact[];
- latestRecovery?: RecoverableArtifact;
+ /**
+ * Newest completed PBS snapshot or PVE backup file. Guest snapshots never
+ * fill this slot: they live on the guest's own storage and the protection
+ * posture engine does not count them as independent recovery, so a fresh
+ * snapshot must not read as a fresh backup beside a stale posture.
+ */
+ latestBackup?: RecoverableArtifact;
latestPBS?: RecoverableArtifact;
latestArchive?: RecoverableArtifact;
latestSnapshot?: RecoverableArtifact;
@@ -138,12 +146,25 @@ export function getRecoveryAgeBand(
return 'stale';
}
+// A guest snapshot shares the guest's storage, so it is listed as restore
+// evidence but never counted as a backup. Mirrors the server posture rule that
+// "snapshots alone do not prove independent recovery".
+export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
+ return artifact.sourceKind !== 'snapshot';
+}
+
function parseTimestampMs(value: string | undefined): number | undefined {
if (!value) return undefined;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : undefined;
}
+function archiveFileName(volid: string | undefined): string {
+ const trimmed = volid?.trim() ?? '';
+ if (!trimmed) return '';
+ return trimmed.slice(trimmed.lastIndexOf('/') + 1);
+}
+
function pbsBackupFileDetailLabel(fileCount: number): string {
if (fileCount <= 0) return 'PBS files not listed';
if (fileCount === 1) return '1 PBS file';
@@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel(
size: archive.size,
location: archive.storage || archive.node || '—',
...artifactLocationScope('archive', archive.instance, archive.storage || archive.node),
- detail: archive.volid || archive.format || archiveSource.detailFallbackLabel,
+ // The volid repeats the storage, guest type, VMID, and timestamp the row
+ // already shows in their own columns and clips at every width; the
+ // archive format is the one fact it adds, so that is the cell and the
+ // volid stays on hover and in search.
+ detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel,
+ detailTitle: archive.volid || undefined,
protected: archive.protected,
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
running: archive.inProgress === true,
@@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel(
// the "latest" pointers (which answer "what can I recover to?") skip them.
// The artifacts themselves stay listed with an honest state.
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
+ row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs);
row.latestPBS = newest(
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
(artifact) => artifact.createdMs,
@@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel(
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
(artifact) => artifact.createdMs,
);
- row.latestRecovery = newest(completed, (artifact) => artifact.createdMs);
}
const coverageRows = Array.from(rows.values()).map((row) => {
@@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel(
coverageRows.sort((left, right) => {
if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank;
- return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0);
+ return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0);
});
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
@@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch(
artifact.sourceTitle,
artifact.location,
artifact.detail,
+ artifact.detailTitle,
artifact.verified === true
? 'verified'
: artifact.verified === false
diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts
index b1342d3396..92e3e2f3a0 100644
--- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts
+++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts
@@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record<
> = {
// Reachability and datastore exhaustion are the two risks that can stop all
// future backups. Host telemetry and space-efficiency context return only as
- // the table gains enough room to keep those headline answers readable.
- compact: ['server', 'status', 'datastore', 'used', 'backups'],
+ // the table gains enough room to keep those headline answers readable. The
+ // phone projection keeps those two answers whole ("Healthy", "33.6%") and
+ // lets the backup count return with the first wider layout.
+ compact: ['server', 'status', 'datastore', 'used'],
basic: ['server', 'status', 'datastore', 'used', 'backups'],
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
@@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial>
> = {
- compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 },
- basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 },
+ compact: { server: 40, status: 23, datastore: 22, used: 15 },
+ // Just above the phone projection the used cell carries its used/total
+ // pair again, the longest value in the row, so it takes the most room.
+ basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 },
operational: {
server: 20,
status: 12,
@@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] =
{ id: 'targetId', label: 'Target ID', kind: 'text' },
{ id: 'node', label: 'Node', kind: 'text' },
{ id: 'posture', label: 'Posture', kind: 'text' },
- { id: 'latest', label: 'Restore', kind: 'numeric-value' },
+ { id: 'latest', label: 'Last backup', kind: 'numeric-value' },
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
{ id: 'archive', label: 'PVE file', kind: 'text' },
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
@@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] =
const COVERAGE_VISIBLE: Record = {
// On narrow surfaces answer: which workload, what posture, how recent is the
- // newest restore point, and did the latest task succeed? Provider-by-provider
+ // newest independent backup, and did the latest task succeed? Provider-by-provider
// evidence is progressive detail in the expansion row; target identity folds
// beneath the name so the scan stays legible at 320px.
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
@@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial>
> = {
- compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 },
+ // Posture keeps its whole compact word ("Unknown"), the two age cells hold
+ // the suffix-free compact age, and the job dot takes what a "Job" header
+ // needs.
+ compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 },
// Above the phone projection the identity columns (workload, node) take the
// slack that the short type badge, target id, and age cells cannot use, so
// names stay whole instead of truncating beside empty space.
- basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 },
+ basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
expanded: {
workload: 19,
@@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record getRecoveryAgeBand(props.artifact.createdMs);
const title = () => {
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
@@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
return (
-
+ }
+ >
+ {formatCompactBackupAge(props.artifact.createdAt)}
+
);
}
diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts
index b053bdb00f..1a9d7db7cd 100644
--- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts
+++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record = {
- node: 18,
- version: 7,
+ node: 20,
+ version: 6,
uptime: 7,
cpu: 13,
memory: 13,
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record = {
temp: 6,
vms: 5,
cts: 5,
- cluster: 13,
+ cluster: 12,
};
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
disk: 15,
uptime: 15,
},
+ // Phones carry the same five columns as the guest table below them. A sixth
+ // (temperature) left the node name with four characters, which made a
+ // cluster of "Analytics A".."F" rows unreadable.
phone: {
node: 32,
- cpu: 13,
- memory: 13,
- disk: 13,
- temp: 15,
- uptime: 14,
+ cpu: 15,
+ memory: 15,
+ disk: 15,
+ uptime: 15,
},
mobile: {
node: 32,
@@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = (
);
};
+// The node cell carries the expansion toggle and status dot before the name,
+// so it needs more than the shared 30% phone identity anchor to show a
+// typical node name whole.
+const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
+
export const getProxmoxHostColumnWidthStyle = (
columnId: ProxmoxHostTableColumnId,
layoutMode: WorkloadTableLayoutMode,
@@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = (
visibleColumnIds,
layoutMode === 'narrow'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
- : layoutMode === 'phone' || layoutMode === 'mobile'
- ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
- : undefined,
+ : layoutMode === 'phone'
+ ? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT }
+ : layoutMode === 'mobile'
+ ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
+ : undefined,
);
};
diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts
index 8edf3ca711..89a8caacb3 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts
@@ -4,6 +4,7 @@ import {
DISK_DETAIL_LIVE_CHARTS,
getDiskAttributeValueTextClass,
getDiskDetailAttributeCards,
+ getDiskDetailHealthPresentation,
getDiskDetailHistoryCharts,
getDiskDetailHistoryFallbackMessage,
getDiskDetailLiveBadgeLabel,
@@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => {
);
});
+ it('carries the row verdict and its reason into the drawer header', () => {
+ const disk = {
+ node: 'tower',
+ instance: 'cluster-main',
+ devPath: '/dev/sda',
+ model: 'Archive HDD',
+ serial: 'SERIAL-1',
+ wwn: '',
+ size: 1,
+ health: 'PASSED',
+ wearout: -1,
+ type: 'hdd',
+ temperature: 42,
+ rpm: 7200,
+ used: '',
+ riskReasons: [] as string[],
+ };
+ expect(getDiskDetailHealthPresentation(disk)).toEqual({
+ label: 'Healthy',
+ summary: '',
+ tone: 'text-emerald-700 dark:text-emerald-300',
+ });
+ expect(
+ getDiskDetailHealthPresentation({
+ ...disk,
+ health: 'FAILED',
+ riskReasons: ['SMART failed.'],
+ }),
+ ).toEqual({
+ label: 'Replace Now',
+ summary: 'SMART failed.',
+ tone: 'text-red-700 dark:text-red-300',
+ });
+ });
+
it('builds canonical SATA and NVMe attribute cards', () => {
expect(
getDiskDetailAttributeCards({
diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts
index 4d177523c1..b6823bd0f7 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts
@@ -19,9 +19,11 @@ import {
PHYSICAL_DISK_TABLE_CLASS,
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
getPhysicalDiskEmptyStatePresentation,
- getPhysicalDiskColumnWidthPercent,
+ getPhysicalDiskCellPaddingClass,
+ getPhysicalDiskColumnWidthStyle,
getPhysicalDiskCollectionMessages,
getPhysicalDiskFieldStatusMessage,
+ getPhysicalDiskHealthCompactLabel,
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
@@ -158,14 +160,55 @@ describe('diskPresentation', () => {
),
),
).toBe(true);
- expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33);
- expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14);
- expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9);
- expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0);
+ expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' });
+ expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' });
+ expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' });
+ expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' });
+ expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' });
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
- expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40);
- expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15);
+ expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' });
+ expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' });
+ expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' });
+ });
+
+ it('keeps the desktop disk column widths on the canonical weighted helper', () => {
+ const columns = [
+ 'disk',
+ 'device',
+ 'host',
+ 'role',
+ 'parent',
+ 'health',
+ 'life',
+ 'temp',
+ 'size',
+ ] as const;
+ expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([
+ '19%',
+ '9%',
+ '10%',
+ '8%',
+ '12%',
+ '17%',
+ '6%',
+ '7%',
+ '12%',
+ ]);
+ });
+
+ it('sheds the shared cell padding only on the phone disk layouts', () => {
+ expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('!px-1');
+ expect(getPhysicalDiskCellPaddingClass('compact')).toBe('!px-1');
+ expect(getPhysicalDiskCellPaddingClass('basic')).toBe('');
+ expect(getPhysicalDiskCellPaddingClass('full')).toBe('');
+ });
+
+ it('shortens only the health words that cannot fit a phone health column', () => {
+ expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention');
+ expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace');
+ expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy');
+ expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown');
});
it('detects SMART warnings from counters', () => {
diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
index 14cd9deebd..5185db68c3 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts
@@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => {
),
).toBe('Datastore');
expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage');
+ // Proxmox plugin names are acronyms or compound words; title-casing them
+ // produced "Lvmthin" for every default local-lvm storage and "Nfs".
+ expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI');
+ expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS');
expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe(
'Rebuild Target',
);
diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts
index 99ee1f17b7..a23aa645a0 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts
@@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => {
expect(getStoragePoolStateLabel(record)).toBe('Online');
});
- it('returns the raw pool state when it is anything other than "ONLINE"', () => {
+ it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
const record = {
...baseRecord(),
details: { zfsPool: { state: 'FAULTED', devices: [] } },
} as unknown as StorageRecord;
- expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
+ expect(getStoragePoolStateLabel(record)).toBe('Faulted');
});
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts
index 0c2c8f0b59..27d8fff8e6 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts
@@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
expect(getStoragePoolStateLabel(record)).toBe('Online');
});
- it('returns the raw pool state when it is anything other than "ONLINE"', () => {
+ it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
const record = {
...baseRecord(),
details: {
@@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
},
},
} as unknown as StorageRecord;
- expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
+ expect(getStoragePoolStateLabel(record)).toBe('Faulted');
});
it('titleizes the derived status when arrayState and pool are both absent', () => {
diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts
index 1afe8dff92..8787f8637d 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts
@@ -101,6 +101,47 @@ describe('storage row presentation', () => {
expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity');
});
+ it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => {
+ const resilvering = {
+ ...baseRecord(),
+ rebuildInProgress: true,
+ protectionLabel: 'ZFS pool tank is resilvering',
+ details: {
+ zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] },
+ },
+ } as unknown as StorageRecord;
+ expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering');
+ expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering');
+
+ const scrubbing = {
+ ...baseRecord(),
+ rebuildInProgress: true,
+ protectionLabel: 'ZFS pool tank scrub is running (45.2%)',
+ protectionSummary: 'ZFS pool tank scrub is running (45.2%)',
+ details: {
+ zfsPool: {
+ state: 'ONLINE',
+ scan: '',
+ scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 },
+ devices: [],
+ },
+ },
+ } as unknown as StorageRecord;
+ expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%');
+ expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe(
+ 'ZFS pool tank scrub is running (45.2%)',
+ );
+
+ // A rebuild without zfs scan data keeps the label its platform supplied.
+ const parity = {
+ ...baseRecord(),
+ rebuildInProgress: true,
+ protectionLabel: 'Parity sync 12%',
+ } as unknown as StorageRecord;
+ expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%');
+ expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%');
+ });
+
it('derives zfs issue fallback from pool state and errors', () => {
const record = {
...baseRecord(),
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
index 96866fbeda..7e5917d601 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts
@@ -467,6 +467,37 @@ describe('storageAdapters', () => {
expect(records[0].hostLabel).toBe('pve1');
});
+ it('labels a poller-merged shared storage as shared with its node count', () => {
+ const records = buildStorageRecords({
+ state: baseState(),
+ resources: [
+ makeResourceStorage({
+ id: 'cluster-a-cluster-nfs-iso',
+ name: 'nfs-iso',
+ parentName: 'cluster',
+ platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true },
+ storage: {
+ type: 'nfs',
+ shared: true,
+ nodes: ['pve1', 'pve2', 'pve3'],
+ } as Resource['storage'],
+ }),
+ makeResourceStorage({
+ id: 'pve2-local-lvm',
+ name: 'local-lvm',
+ parentName: 'pve2',
+ platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false },
+ storage: { type: 'lvmthin', shared: false } as Resource['storage'],
+ }),
+ ],
+ });
+
+ // The poller folds a shared storage into one entry on the synthetic
+ // "cluster" node; a node-local storage keeps its node name.
+ expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']);
+ expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']);
+ });
+
it('keeps dependency impact separate from healthy storage primary issues', () => {
const records = buildStorageRecords({
state: baseState(),
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts
index a2b1c04bed..86ec88080b 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts
@@ -13,6 +13,7 @@ import {
STORAGE_BAR_ZFS_SECTION_CLASS,
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
+ getStorageBarCompactLabel,
getStorageBarLabel,
getStorageBarTooltipRowClass,
getStorageBarTooltipRows,
@@ -39,6 +40,7 @@ describe('storageBarPresentation', () => {
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
+ expect(getStorageBarCompactLabel(40, 100)).toBe('40%');
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
expect(getStorageBarTooltipRowClass(true)).toBe(
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts
index a8971d42b9..b909d2e8f5 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
- getStoragePoolColumnWidthPercent,
+ getStoragePoolCellPaddingClass,
+ getStoragePoolColumnWidthStyle,
getStoragePoolTableColumns,
getStoragePoolTableLayoutModeForContainer,
getStorageTableHeading,
@@ -46,22 +47,44 @@ describe('storagePagePresentation', () => {
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
- expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact');
- expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational');
+ // The compact/operational boundary is the shared phone container query
+ // (34rem), so the usage bar label and the column set change together.
+ expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact');
+ expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational');
expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational');
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false);
- expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21);
+ expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' });
+ expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' });
expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true);
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
- expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35);
- expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25);
- expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0);
+ expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' });
+ expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' });
+ expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' });
+ expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' });
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
- expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40);
- expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20);
+ expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' });
+ expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' });
+ });
+
+ it('sheds the desktop cell gutter only on the phone pool layouts', () => {
+ expect(getStoragePoolCellPaddingClass('narrow')).toBe('!px-1');
+ expect(getStoragePoolCellPaddingClass('compact')).toBe('!px-1');
+ expect(getStoragePoolCellPaddingClass('operational')).toBe('');
+ expect(getStoragePoolCellPaddingClass('full')).toBe('');
+ });
+
+ it('keeps the desktop pool column widths on the canonical weighted helper', () => {
+ const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map(
+ (column) =>
+ getStoragePoolColumnWidthStyle(
+ 'full',
+ column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth',
+ ).width,
+ );
+ expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']);
});
});
diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
index 12cc6a8f12..449020cb8f 100644
--- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts
@@ -1,4 +1,8 @@
-import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
+import {
+ getPhysicalDiskHealthStatus,
+ getPhysicalDiskHealthSummary,
+ type PhysicalDiskPresentationData,
+} from '@/features/storageBackups/diskPresentation';
import type { HistoryTimeRange } from '@/api/charts';
import { formatPowerOnHours } from '@/utils/format';
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
@@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time';
export const getDiskDetailHistoryFallbackMessage = (): string =>
'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.';
+export type DiskDetailHealthPresentation = {
+ label: string;
+ summary: string;
+ tone: string;
+};
+
+// The drawer header carries the disk's verdict and its reason. The table row
+// shows only the verdict word, so this is where the reason is read in full.
+export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1';
+export const DISK_DETAIL_HEALTH_ROW_CLASS =
+ 'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]';
+export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold';
+export const DISK_DETAIL_HEALTH_SUMMARY_CLASS =
+ 'min-w-0 max-w-full whitespace-normal break-words text-muted';
+
+export function getDiskDetailHealthPresentation(
+ disk: PhysicalDiskPresentationData,
+): DiskDetailHealthPresentation {
+ const status = getPhysicalDiskHealthStatus(disk);
+ return {
+ label: status.label,
+ summary: getPhysicalDiskHealthSummary(status),
+ tone: status.tone,
+ };
+}
+
export function getDiskDetailAttributeCards(
disk: PhysicalDiskPresentationData,
diskTempThresholds?: MetricDisplayThresholds | null,
diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts
index 9cf9707e53..5d4aee37ca 100644
--- a/frontend-modern/src/features/storageBackups/diskPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts
@@ -1,8 +1,10 @@
+import type { JSX } from 'solid-js';
import type {
PhysicalDiskCollectionStatus,
PhysicalDiskFieldStatus,
Resource,
} from '@/types/resource';
+import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
import {
getSourcePlatformLabel,
getSourcePlatformPresentation,
@@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record<
full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'],
};
-const PHYSICAL_DISK_COLUMN_WIDTHS: Record<
+// Relative weights per layout, resolved through the canonical weighted-width
+// helper. The phone layouts are sized from the measured values they show:
+// a temperature ("100°C"), a capacity ("5.46 TB"), a compact health word and
+// a short node name must each fit whole, and the disk model takes the rest.
+const PHYSICAL_DISK_COLUMN_WEIGHTS: Record<
PhysicalDiskTableLayoutMode,
Partial>
> = {
- narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 },
- compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 },
+ narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 },
+ compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 },
basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 },
operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 },
expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 },
@@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = (
columnId: PhysicalDiskTableColumnId,
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
-export const getPhysicalDiskColumnWidthPercent = (
+export const getPhysicalDiskColumnWidthStyle = (
layout: PhysicalDiskTableLayoutMode,
columnId: PhysicalDiskTableColumnId,
-): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0;
+): JSX.CSSProperties =>
+ getPlatformTableWeightedColumnWidthStyle(
+ columnId,
+ PHYSICAL_DISK_COLUMN_WEIGHTS[layout],
+ PHYSICAL_DISK_VISIBLE_COLUMNS[layout],
+ );
+
+// The shared table cell primitives pad every cell for desktop density. On the
+// phone layouts that padding alone is a fifth of the row, so rendered cells
+// and headers shed it the same way the Proxmox replication table does.
+export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = '!px-1';
+
+export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string =>
+ layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : '';
+
+// Health words that do not fit a phone-width health column fall back to a
+// shorter form; the full label stays in the wider projections and the detail.
+export const getPhysicalDiskHealthCompactLabel = (label: string): string => {
+ if (label === 'Needs Attention') return 'Attention';
+ if (label === 'Replace Now') return 'Replace';
+ return label;
+};
export const PHYSICAL_DISK_COL_DISK_CLASS = '';
export const PHYSICAL_DISK_COL_DEVICE_CLASS = '';
@@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted';
export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium';
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
-export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold';
-export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
+// The health word is the row's verdict and always renders whole. Its reason
+// lives in the cell title and in the disk drawer, not in a truncating sibling
+// that would compete with it for the track.
+export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
index 0c3b5d618a..9302b84d66 100644
--- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
+++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts
@@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = (
case 'rbd':
case 'cephfs':
return 'Cluster Storage';
+ // Proxmox storage plugin names are acronyms or compound words that
+ // title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use
+ // the names the Proxmox UI shows for them.
+ case 'nfs':
+ return 'NFS';
+ case 'cifs':
+ case 'smb':
+ return 'SMB/CIFS';
+ case 'lvm':
+ return 'LVM';
+ case 'lvmthin':
+ case 'lvm-thin':
+ return 'LVM-Thin';
+ case 'iscsi':
+ case 'iscsidirect':
+ return 'iSCSI';
+ case 'zfs':
+ return 'ZFS';
+ case 'btrfs':
+ return 'BTRFS';
+ case 'glusterfs':
+ return 'GlusterFS';
+ case 'esxi':
+ return 'ESXi';
default:
return titleize(storageType) || titleize(resource.type) || 'Storage';
}
diff --git a/frontend-modern/src/features/storageBackups/rowPresentation.ts b/frontend-modern/src/features/storageBackups/rowPresentation.ts
index 10e4c68f7a..f9bacbab41 100644
--- a/frontend-modern/src/features/storageBackups/rowPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/rowPresentation.ts
@@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string {
return 'text-base-content';
}
+// The rebuild summary the backend promotes into the protection label is a
+// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is
+// a badge, so a ZFS scan reads as the activity word, derived from the pool's
+// own scan state (the same source the backend keys its risk reason on); the
+// sentence stays available as the cell title. Non-ZFS rebuilds keep the label
+// their platform supplies.
+export function getStoragePoolRebuildLabel(record: StorageRecord): string {
+ const pool = getStorageRecordZfsPool(record);
+ if (!pool) return '';
+ const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase();
+ const percentage = pool.scanDetails?.percentage;
+ const progress =
+ typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0
+ ? ` ${Math.round(percentage)}%`
+ : '';
+ if (scan.includes('resilver')) return `Resilvering${progress}`;
+ if (scan.includes('scrub')) return `Scrubbing${progress}`;
+ return '';
+}
+
export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string {
const label = (record.protectionLabel || '').trim();
- if (record.rebuildInProgress || record.protectionReduced) {
+ if (record.rebuildInProgress) {
+ return getStoragePoolRebuildLabel(record) || label || '—';
+ }
+ if (record.protectionReduced) {
return label || '—';
}
if (label && label.toLowerCase() !== 'healthy') {
@@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string {
}
const pool = getStorageRecordZfsPool(record);
if (pool?.state) {
- return pool.state === 'ONLINE' ? 'Online' : pool.state;
+ // zpool reports upper-case states; present them like every other state
+ // path so a DEGRADED pool reads the same as a degraded array.
+ return titleize(pool.state);
}
const status = getStorageRecordStatus(record);
return status ? titleize(status) : '—';
@@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str
return issueSummary;
}
}
- return label;
+ // When the badge shows a derived word, the platform's full label is the title.
+ const fullLabel = (record.protectionLabel || '').trim();
+ return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label;
}
export function getCompactStoragePoolImpactLabel(record: StorageRecord): string {
diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts
index 19b8ac6598..7e22fdd6d7 100644
--- a/frontend-modern/src/features/storageBackups/storageAdapters.ts
+++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts
@@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag
const usedBytes = asNumberOrNull(resource.disk?.used);
const freeBytes = asNumberOrNull(resource.disk?.free);
const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform);
- const hostLabel = locationLabel;
+ // The poller folds a shared storage into one entry whose node is the
+ // synthetic "cluster" marker (the mock uses "shared"); show that as a
+ // shared storage with its reporting node count rather than a node name.
+ const sharedLocationMarkers = new Set(['cluster', 'shared']);
+ const hostLabel =
+ shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase())
+ ? storageNodes.length > 1
+ ? `Shared · ${storageNodes.length} nodes`
+ : 'Shared'
+ : locationLabel;
const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform);
const issueLabel = getResourceStorageIssueLabel(resource);
const issueSummary = getResourceStorageIssueSummary(resource);
diff --git a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts
index 02b7fda274..533641e90a 100644
--- a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts
@@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number =
return (used / total) * 100;
};
+// Phone-width tables only have room for the percentage inside the bar; the
+// used/total sublabel stays in the tooltip and in the wider projections.
+export const getStorageBarCompactLabel = (used: number, total: number): string =>
+ formatPercent(getStorageBarUsagePercent(used, total));
+
export const getStorageBarLabel = (used: number, total: number): string =>
- `${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`;
+ `${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`;
export const getStorageBarTooltipTitle = (): string => 'Storage Details';
diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts
index 6d1d641452..26c156fd0e 100644
--- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts
+++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts
@@ -1,5 +1,7 @@
+import type { JSX } from 'solid-js';
import type { StorageSortKey } from './storageModelCore';
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
+import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
export type StorageViewOption = {
value: 'pools' | 'disks';
@@ -21,11 +23,14 @@ export type StoragePoolTableColumn = {
export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full';
+// The compact/operational boundary matches the shared phone projection of the
+// table container (`@container (max-width: 33.999rem)` = 544px), so the
+// columns and the in-cell labels always change projection together.
export const getStoragePoolTableLayoutModeForContainer = (
containerWidth: number,
): StoragePoolTableLayoutMode => {
if (containerWidth >= 1_040) return 'full';
- if (containerWidth >= 560) return 'operational';
+ if (containerWidth >= 544) return 'operational';
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
return 'compact';
};
@@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record<
full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'],
};
-const STORAGE_POOL_COLUMN_WIDTHS: Record<
+// Relative weights per layout, resolved through the canonical weighted-width
+// helper. On phones the usage bar carries only its percentage, so most of the
+// row goes to the two identity strings (pool name and host); a state word such
+// as "Degraded" must still fit whole. The shared 30% identity anchor is not
+// used here because a four-column row cannot keep the host readable with it.
+const STORAGE_POOL_COLUMN_WEIGHTS: Record<
StoragePoolTableLayoutMode,
Partial>
> = {
- narrow: { name: 40, state: 20, host: 20, usage: 20 },
- compact: { name: 35, state: 20, host: 20, usage: 25 },
- operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 },
+ narrow: { name: 35, state: 21, host: 31, usage: 13 },
+ compact: { name: 37, state: 18.5, host: 31.5, usage: 13 },
+ // The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage
+ // track must hold ~120px of text from the first operational width.
+ operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 },
full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 },
};
@@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = (
columnId: StoragePoolTableColumnId,
): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId);
-export const getStoragePoolColumnWidthPercent = (
+// On phones the pool row sheds the desktop cell gutter the same way its
+// headers (via the shared container query) and the physical-disk rows do:
+// four cells at 12px each were 48px of a 361px row, and the measured values
+// fit the tracks with only a pixel or two to spare.
+export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = '!px-1';
+
+export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string =>
+ layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : '';
+
+export const getStoragePoolColumnWidthStyle = (
layout: StoragePoolTableLayoutMode,
columnId: StoragePoolTableColumnId,
-): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0;
+): JSX.CSSProperties =>
+ getPlatformTableWeightedColumnWidthStyle(
+ columnId,
+ STORAGE_POOL_COLUMN_WEIGHTS[layout],
+ STORAGE_POOL_VISIBLE_COLUMNS[layout],
+ );
const STORAGE_POOL_TABLE_HEADER_CLASS =
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider';
diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
index 3408c0079d..ef2dfae357 100644
--- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
+++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts
@@ -2,6 +2,7 @@ import { batch, createRoot, createSignal } from 'solid-js';
import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel';
+import { getDockerContainerLifecycleDisabledReason } from '@/features/docker/dockerContainerLifecycleActions';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw';
@@ -1626,6 +1627,173 @@ describe('useUnifiedResources', () => {
dispose();
});
+ it('keeps PMG metadata on the canonical resource for REST-first paints', async () => {
+ // The Mail Gateway table reads `resource.pmg`. The mapper used to keep it
+ // only under platformData, so a REST-sourced row rendered every PMG column
+ // as a dash until a websocket row happened to replace it.
+ apiFetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ id: 'pmg-1',
+ type: 'pmg',
+ name: 'mail-gateway-eu',
+ status: 'online',
+ lastSeen: '2026-02-06T12:00:00Z',
+ sources: ['pmg'],
+ uptime: 1_555_222,
+ technology: 'pmg',
+ sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
+ actionReadiness: [{ name: 'restart', available: true }],
+ pmg: {
+ instanceId: 'pmg-eu',
+ hostname: 'pmg.example.lan',
+ version: '8.1-2',
+ nodeCount: 1,
+ mailCountTotal: 2_740,
+ spamIn: 321,
+ virusIn: 14,
+ queueTotal: 11,
+ queueDeferred: 5,
+ },
+ },
+ ],
+ }),
+ });
+
+ let dispose = () => {};
+ let result: ReturnType | undefined;
+ createRoot((d) => {
+ dispose = d;
+ result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' });
+ });
+
+ await result!.refetch();
+ const row = result!.resources().find((resource) => resource.id === 'pmg-1');
+ expect(row?.pmg).toEqual(
+ expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }),
+ );
+ expect(row?.platformData?.pmg).toEqual(row?.pmg);
+ // The per-source status, action readiness, and technology facets ride the
+ // same race: Docker lifecycle actions and storage freshness read them at
+ // the top level, where only websocket rows used to carry them.
+ expect(row?.sourceStatus?.pmg?.status).toBe('online');
+ expect(row?.actionReadiness?.[0]?.name).toBe('restart');
+ expect(row?.technology).toBe('pmg');
+
+ dispose();
+ });
+
+ it.each([
+ {
+ label: 'stale inventory',
+ sourceStatus: { docker: { status: 'stale', lastSeen: '2026-02-06T12:00:00Z' } },
+ actionReadiness: [],
+ expected: 'Docker inventory is stale. Refresh inventory before running lifecycle actions.',
+ },
+ {
+ label: 'failed inventory',
+ sourceStatus: {
+ docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z', error: 'Collector refused' },
+ },
+ actionReadiness: [],
+ expected: 'Docker inventory is not healthy: Collector refused',
+ },
+ {
+ label: 'server action refusal',
+ sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
+ actionReadiness: [
+ { name: 'restart', available: false, reason: 'Command agent disconnected' },
+ ],
+ expected: 'Command agent disconnected',
+ },
+ {
+ label: 'no invented action grant',
+ sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } },
+ actionReadiness: [],
+ expected:
+ 'Pulse does not currently advertise a fresh restart command capability for this container.',
+ },
+ ])('preserves $label through REST mapping into lifecycle controls', async (testCase) => {
+ apiFetchMock.mockResolvedValue(
+ resourceResponse([
+ {
+ id: 'docker-container-1',
+ type: 'app-container',
+ name: 'edge-web',
+ status: 'running',
+ lastSeen: '2026-02-06T12:00:00Z',
+ sources: ['docker'],
+ technology: 'docker',
+ docker: { runtime: 'docker', agentId: 'agent-edge', containerState: 'running' },
+ sourceStatus: testCase.sourceStatus,
+ actionReadiness: testCase.actionReadiness,
+ },
+ ]),
+ );
+ let dispose = () => {};
+ let result: ReturnType | undefined;
+ createRoot((d) => {
+ dispose = d;
+ result = useUnifiedResources({ query: 'type=app-container', cacheKey: testCase.label });
+ });
+ try {
+ await result!.refetch();
+ const row = result!.resources()[0];
+ expect(row.sourceStatus).toEqual(testCase.sourceStatus);
+ expect(row.actionReadiness).toEqual(testCase.actionReadiness);
+ expect(row.technology).toBe('docker');
+ expect(getDockerContainerLifecycleDisabledReason(row, 'restart')).toBe(testCase.expected);
+ } finally {
+ dispose();
+ }
+ });
+
+ it('replaces REST facets without retaining a removed PMG counter or action refusal', async () => {
+ const base = {
+ id: 'pmg-1',
+ type: 'pmg',
+ name: 'mail-gateway',
+ status: 'online',
+ sources: ['pmg'],
+ lastSeen: '2026-02-06T12:00:00Z',
+ };
+ apiFetchMock.mockResolvedValue(
+ resourceResponse([
+ {
+ ...base,
+ technology: 'pmg',
+ pmg: { version: '8.1-2', queueTotal: 11 },
+ sourceStatus: { pmg: { status: 'stale', lastSeen: base.lastSeen } },
+ actionReadiness: [{ name: 'restart', available: false, reason: 'Old refusal' }],
+ },
+ ]),
+ );
+ let dispose = () => {};
+ let result: ReturnType | undefined;
+ createRoot((d) => {
+ dispose = d;
+ result = useUnifiedResources({ query: 'type=pmg', cacheKey: 'removed-rest-facets' });
+ });
+ try {
+ await result!.refetch();
+ expect(result!.resources()[0].pmg?.queueTotal).toBe(11);
+ apiFetchMock.mockResolvedValue(
+ resourceResponse([{ ...base, actionReadiness: [], sourceStatus: {} }]),
+ );
+ await result!.refetch();
+ const row = result!.resources()[0];
+ expect(row.pmg).toBeUndefined();
+ expect(row.platformData?.pmg).toBeUndefined();
+ expect(row.actionReadiness).toEqual([]);
+ expect(row.sourceStatus).toEqual({});
+ expect(row.technology).toBeUndefined();
+ } finally {
+ dispose();
+ }
+ });
+
it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => {
// vSphere ESXi hosts and VMs populate only the canonical
// Resource.Uptime field — there's no vmware-specific UptimeSeconds
diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts
index 9093092194..dbb5d9b3d8 100644
--- a/frontend-modern/src/hooks/useUnifiedResources.ts
+++ b/frontend-modern/src/hooks/useUnifiedResources.ts
@@ -18,8 +18,10 @@ import type {
ResourceDockerMeta,
ResourceHealth,
ResourceMetricsTarget,
+ ResourceActionReadiness,
ResourceAvailabilityMeta,
ResourcePBSMeta,
+ ResourcePMGMeta,
ResourcePolicyPostureSummary,
ResourcePhysicalDiskMeta,
ResourcePoolHealth,
@@ -210,6 +212,8 @@ type APIResource = {
sources?: string[];
platformScopes?: string[];
sourceStatus?: Record;
+ actionReadiness?: ResourceActionReadiness[];
+ technology?: string;
health?: ResourceHealth;
identity?: {
machineId?: string;
@@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => {
truenas: v2.truenas as ResourceTrueNASMeta | undefined,
vmware: v2.vmware as ResourceVMwareMeta | undefined,
pbs: v2.pbs as ResourcePBSMeta | undefined,
+ // Mail Gateway rows read `resource.pmg` directly. Leaving it only under
+ // platformData meant a REST-first paint rendered every PMG column as a
+ // dash until a websocket row happened to replace it, and never did when
+ // the REST snapshot landed last.
+ pmg: v2.pmg as ResourcePMGMeta | undefined,
+ // Same race for the per-source status, action readiness, and technology
+ // facets: Docker lifecycle actions and storage freshness read them from
+ // the top level, and the websocket rows carry them there.
+ sourceStatus: v2.sourceStatus,
+ actionReadiness: v2.actionReadiness,
+ technology: v2.technology,
availability: v2.availability as ResourceAvailabilityMeta | undefined,
availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined,
physicalDisk: v2.physicalDisk,
diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
index 839f7bc178..8cf26ffba5 100644
--- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts
@@ -35,6 +35,9 @@ import {
getAlertDestinationsDeliveryLogEmpty,
getAlertDestinationsDeliveryLogTitle,
getAlertDestinationsDeliveryLogUnavailable,
+ getAlertDestinationsDeliveryLogLoading,
+ getAlertDestinationsHeldEventsLoading,
+ getAlertDestinationsHeldEventsUnavailable,
getAlertDestinationsTestPausedWarning,
getAlertDestinationsAppriseTargetsHelp,
getAlertDestinationsAppriseTestLabel,
@@ -267,6 +270,28 @@ describe('alert destinations delivery log copy', () => {
);
});
+ it('distinguishes pending and unavailable activity sources without implying delivery failure', () => {
+ expect(getAlertDestinationsDeliveryLogLoading()).toBe('Loading delivery attempts...');
+ expect(getAlertDestinationsHeldEventsLoading()).toBe(
+ 'Loading held and deferred notifications...',
+ );
+ expect(getAlertDestinationsHeldEventsUnavailable()).toBe(
+ 'Pulse could not read held or deferred notifications. Refresh to try again.',
+ );
+ expect(getAlertDestinationsDeliveryLogUnavailable()).toBe(
+ 'Pulse could not read the delivery log. Refresh to check delivery attempts.',
+ );
+ for (const message of [
+ getAlertDestinationsDeliveryLogLoading(),
+ getAlertDestinationsHeldEventsLoading(),
+ getAlertDestinationsHeldEventsUnavailable(),
+ getAlertDestinationsDeliveryLogUnavailable(),
+ ]) {
+ expect(message).not.toContain('No alert deliveries');
+ expect(message).not.toContain('not delivered');
+ }
+ });
+
it('warns that a passing test does not mean live alerts flow while delivery is paused', () => {
const warning = getAlertDestinationsTestPausedWarning();
expect(warning).toContain('Test sent');
diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts
index 92ad797dc7..1fc3454103 100644
--- a/frontend-modern/src/utils/alertDestinationsPresentation.ts
+++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts
@@ -277,7 +277,19 @@ export const ALERT_DESTINATIONS_DELIVERY_LOG_TITLE = 'Recent delivery activity';
export const ALERT_DESTINATIONS_DELIVERY_LOG_EMPTY =
'No alert deliveries were attempted in this window.';
export const ALERT_DESTINATIONS_DELIVERY_LOG_UNAVAILABLE =
- 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.';
+ 'Pulse could not read the delivery log. Refresh to check delivery attempts.';
+
+export function getAlertDestinationsHeldEventsUnavailable() {
+ return 'Pulse could not read held or deferred notifications. Refresh to try again.';
+}
+
+export function getAlertDestinationsDeliveryLogLoading() {
+ return 'Loading delivery attempts...';
+}
+
+export function getAlertDestinationsHeldEventsLoading() {
+ return 'Loading held and deferred notifications...';
+}
export function getAlertDestinationsDeliveryLogTitle() {
return ALERT_DESTINATIONS_DELIVERY_LOG_TITLE;