+
+
+
+
+
+
+
+
+
+
+ );
+}
+void updateStore.checkForUpdates();
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index bd2176689c..34fd143392 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,23 +1,67 @@
{
"version": 1,
"result": "passed",
- "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
- "verified_at": "2026-10-03T04:13:25.499572Z",
+ "base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4",
+ "verified_at": "2026-10-03T06:30:17.630790Z",
"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/components/Infrastructure/ResourceDetailSummary.tsx",
+ "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
+ "frontend-modern/src/components/Settings/UpdateHistorySection.tsx",
+ "frontend-modern/src/components/Storage/DiskDetail.tsx",
+ "frontend-modern/src/components/Storage/DiskList.tsx",
+ "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx",
+ "frontend-modern/src/components/Storage/StoragePoolRow.tsx",
+ "frontend-modern/src/components/Storage/StoragePoolsTable.tsx",
+ "frontend-modern/src/components/Storage/useDiskDetailModel.ts",
+ "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts",
+ "frontend-modern/src/components/Workloads/guestRowModel.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
+ "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx",
+ "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts",
+ "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts",
+ "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx",
+ "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
+ "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts",
+ "frontend-modern/src/features/storageBackups/diskPresentation.ts",
+ "frontend-modern/src/features/storageBackups/rowPresentation.ts",
+ "frontend-modern/src/features/storageBackups/storageBarPresentation.ts",
+ "frontend-modern/src/features/storageBackups/storagePagePresentation.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/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8",
+ "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51",
+ "frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "39e7f5daa49bd18fd71d91a09a06da47ec3abc5496108d3715020180ca439e52",
+ "frontend-modern/src/components/Storage/DiskDetail.tsx": "04e5bacb4900cc77a94fdb804363048ed53f4b84dc1c0e9610878a106a1db1a6",
+ "frontend-modern/src/components/Storage/DiskList.tsx": "47da15179f6c7e98583d9db2a858790233660f8dc4eb9ea85681f262b50514f5",
+ "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "ed72a33e754e96ebd03374c34f9732b02411cd9055667f4e0051d6f7e66ab653",
+ "frontend-modern/src/components/Storage/StoragePoolRow.tsx": "09417822bd543ddbc312d0c15a4ea74971066d6623d421d889858e56262b9d3d",
+ "frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "7b61e9fac5e3a9fbae831a3cf907a71f1be7158f06bfe7a9fe7d16fce7a2c928",
+ "frontend-modern/src/components/Storage/useDiskDetailModel.ts": "ee5059bb3a7e0069561e4f103e2fd89101e096ad32b3b27c6f4777c26c1388f8",
+ "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts": "d03daecfe22ede32e46edc74a20e4ac11d732d4f9ca202c0b6f36c22a590aa44",
+ "frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639",
+ "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "adbae773cc485f5dc16d1d316bbd5e1b1ace6be24404861ce02bf7dd8890bb25",
+ "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "786be66c40d33006edc1eda234df083ee68e0296103882ff108711026dd2ea29",
+ "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "6a546a5c1e8a9bbe6a2bdeac61c988a8e9f35cf96888adff245a5efd7017769e",
+ "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "fdc9fc2720ed04771f5c9d271f8972a4dece25e165064183fcf59678466a970e",
+ "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2",
+ "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "dc2641e74f12ed01b5e2a1424f2223c8ca136fa4e0321dd49a79e5ce458fc3cd",
+ "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "2ab0f6c58e18337f05ea85082ed5c465a625c5388ff82987eec41bbbe3005915",
+ "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "4aab84754245dda70c3b3be3e81b15875a8664d38c8d0325329b2074b6c26fd5",
+ "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "4366372e6acf67ac62e03a2c9c34e599d304724a1c79e17b60503bdae9aa6fe3",
+ "frontend-modern/src/features/storageBackups/diskPresentation.ts": "fc637ecfee6ffff80ee826bdb8ff546ef0dd8ffa5cd0790c19b1fdcf71dcfb65",
+ "frontend-modern/src/features/storageBackups/rowPresentation.ts": "cabd37e3f736467aecff68359fcb97be08bcabbe5c5ec6e0a0da2fe67d2e3261",
+ "frontend-modern/src/features/storageBackups/storageBarPresentation.ts": "562eac2212378e98823717f712c791133cebf1f44bd71c75c7db097b244f6cc0",
+ "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "2a93a0176b912283c77b384ee2c66c4bc62c21a04b645eb93917a41740ad2d6e"
},
"routes": [
- "Existing PBS/resource detail Overview change history",
- "Existing resource detail Manage action history"
+ "Existing storage pool and physical-disk tables / disk expansion",
+ "Existing Kubernetes controller table / production resource summary expansion",
+ "Existing Proxmox coverage, By date, PBS/node tables and guest row",
+ "Existing Settings Update History confirmation"
],
"viewports": [
{
@@ -27,26 +71,31 @@
{
"width": 390,
"height": 844
+ },
+ {
+ "width": 768,
+ "height": 900
}
],
"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"
+ "Completed backup stays 13d old alongside a separate fresh 30m guest snapshot; missing independent backup remains unit-covered",
+ "Structured ZFS scrub/resilver activity badges render at desktop/intermediate width; phone retains Degraded/Online and measured 59% capacity",
+ "Disk table verdict keeps full SMART reason in the expanded header without phone overflow",
+ "Job and CronJob absolute timestamps, elapsed duration, target, schedule, suspension and last-success remain reachable from phone row expansion",
+ "Consent names running and selected historical versions separately and declares absent restore scope; legacy and installation-only effects are distinguished",
+ "Rejected rollback keeps consent and error toast; pending actions cannot be duplicated or cancelled",
+ "All sixteen final screenshots inspected; Playwright 1.56.1 matches integration lock, light desktop/intermediate Chromium and dark touch phone WebKit"
],
"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"
+ "Expand/collapse real resource and disk rows",
+ "Open consent using keyboard Enter, cancel and return focus without a rollback write",
+ "Reject one synthetic rollback POST, then hold/accept a second while checking disabled duplicate/cancel and Escape behaviour",
+ "Select existing backup projections with independent archive and guest snapshot dates"
],
"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."
+ "Synthetic observations and HTTP responses through production components and CSS in a local Vite preview. Not native appliance data/authorisation, reporter cause, released availability, actual rollback, Tailscale or SSE acceptance.",
+ "The History API exposes no restore-scope metadata; no version heuristic or free-form note establishes scope. The UI conservatively says it is unknown.",
+ "Full provider identifiers remain titles/search targets. Some long row names and secondary intermediate-width fields intentionally truncate; this does not prove every native estate or every value fits.",
+ "The frontend graph is unchanged and its seven high GHSA-vfj7-8cjw-p6xm findings remain; browser/component/build proof is not audit 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.
- This restores the binary, configuration, and data from the backup taken before the
- update to {formatVersionLabel(entry().version_to)} on{' '}
- {formatTimestamp(entry().timestamp)}.
+
- 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 (
-
- {status().label}
-
-
+ {/* The row shows the verdict word only; the reason is the
+ cell's title here and is spelled out in the disk drawer.
+ The phone projection of the table container shows the
+ shorter word; the tone stays on each span so the rendered
+ label is the styled element in either projection. */}
+
+ {status().label}
+
+ }
+ >
- {healthSummary()}
+ {healthCompactLabel()}
+
+
+ {status().label}
= {}): Disk {
return {
@@ -46,7 +48,10 @@ describe('DiskList', () => {
'agent-not-running',
'Guest agent not running. Install and start qemu-guest-agent in the VM.',
],
- ['agent-timeout', 'Guest agent timeout. Agent may need to be restarted.'],
+ [
+ 'agent-timeout',
+ 'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.',
+ ],
[
'permission-denied',
'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).',
@@ -59,6 +64,11 @@ describe('DiskList', () => {
],
['agent-error', 'Error communicating with guest agent.'],
['no-data', 'No disk data available from Proxmox API.'],
+ ['vm-stopped', 'Guest filesystem stats unavailable while the VM is stopped.'],
+ [
+ 'no-status',
+ 'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.',
+ ],
[
'prev-no-filesystems',
'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.',
@@ -78,6 +88,30 @@ describe('DiskList', () => {
});
describe('rendering disks', () => {
+ it.each(guestDiskDeferrals)(
+ 'keeps retained values and explains %s until fresh data resumes',
+ (reason, message) => {
+ const [disks, setDisks] = createSignal([makeDisk()]);
+ const [status, setStatus] = createSignal(`prev-${reason}`);
+ render(() => );
+ expect(screen.getByText(`Using last known disk stats. ${message}`)).toBeInTheDocument();
+ expect(screen.getByText('50%')).toBeInTheDocument();
+ setDisks([makeDisk({ used: 80530636800, usage: 75 })]);
+ setStatus(undefined);
+ expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
+ expect(screen.getByText('75%')).toBeInTheDocument();
+ },
+ );
+
+ it.each(guestDiskDeferrals)(
+ 'explains %s without claiming previous values when none exist',
+ (reason, message) => {
+ render(() => );
+ expect(screen.getByText('-')).toHaveAttribute('title', message);
+ expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
+ },
+ );
+
it('renders a single disk with correct label, usage, and type', () => {
const disk = makeDisk({ mountpoint: '/data', type: 'xfs' });
render(() => );
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
index 38e5e81bce..bb27528599 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
@@ -1,7 +1,8 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
-import type { ComponentProps } from 'solid-js';
+import { createSignal, type ComponentProps } from 'solid-js';
import { afterEach, describe, expect, it } from 'vitest';
import { GuestDrawerOverview } from '../GuestDrawerOverview';
+import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
const paths = ['/mnt/Plex/Media/Animation/Films', '/mnt/Plex/Media/Animation/Series'];
const props: ComponentProps = {
@@ -55,6 +56,47 @@ const props: ComponentProps = {
describe('GuestDrawerOverview filesystem labels', () => {
afterEach(cleanup);
+ it.each(guestDiskDeferrals)(
+ 'explains %s in the existing drawer even before any disk sample',
+ (reason, message) => {
+ render(() => (
+
+ ));
+ expect(screen.getByText('Filesystems')).toBeInTheDocument();
+ expect(screen.getByText(message)).toBeInTheDocument();
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+ expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
+ },
+ );
+
+ it('retains disk rows with readable status, then withdraws the notice on fresh resumption', () => {
+ const [guest, setGuest] = createSignal({
+ ...props.guest,
+ type: 'qemu' as const,
+ diskStatusReason: 'prev-vm-locked',
+ });
+ render(() => );
+ expect(
+ screen.getByText(/^Using last known disk stats\. Guest reads paused/).closest('td'),
+ ).toHaveAttribute('colspan', '2');
+ expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument();
+ for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument();
+ setGuest({
+ ...guest(),
+ diskStatusReason: '',
+ disks: guest().disks!.map((disk) => ({ ...disk, usage: 75 })),
+ });
+ expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
+ expect(screen.queryByText('Status')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('progressbar', { name: `Filesystem ${paths[0]} utilization` }),
+ ).toHaveAttribute('aria-valuenow', '75');
+ });
+
it('shows linked-agent RAID arrays beside the guest storage evidence', () => {
render(() => (
({
}));
vi.mock('../StackedDiskBar', () => ({
- StackedDiskBar: () => ,
+ StackedDiskBar: (props: { statusMessage?: string }) => (
+
+ ),
}));
vi.mock('../StackedMemoryBar', () => ({
@@ -668,6 +671,39 @@ describe('GuestRow', () => {
});
describe('lock label', () => {
+ it.each(guestDiskDeferrals)(
+ 'annotates retained row and disk tooltip for %s',
+ (reason, message) => {
+ const { container } = renderGuestRow({
+ guest: makeGuest({ diskStatusReason: `prev-${reason}` }),
+ });
+ const expected = `Using last known disk stats. ${message}`;
+ expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute(
+ 'title',
+ expected,
+ );
+ expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', expected);
+ },
+ );
+
+ it('clears retained status for a same-VM fresh update in sparkline mode without remounting', () => {
+ const [guest, setGuest] = createSignal(
+ makeGuest({ diskStatusReason: 'prev-agent-cooldown' }),
+ );
+ const { container } = render(() => (
+
+
+
+
+
+ ));
+ const cell = container.querySelector('[data-workload-col="disk"]');
+ expect(cell).toHaveAttribute('title', expect.stringContaining('Using last known'));
+ setGuest(makeGuest({ diskStatusReason: undefined, disk: makeDisk({ usage: 75 }) }));
+ expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell);
+ expect(cell).not.toHaveAttribute('title');
+ });
+
it('shows lock label when guest is locked', () => {
renderGuestRow({ guest: makeGuest({ lock: 'migrate' }) });
expect(screen.getByText(/Lock:.*migrate/)).toBeTruthy();
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
index 391101ac73..9cd200c640 100644
--- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx
@@ -343,6 +343,17 @@ describe('StackedDiskBar', () => {
// ── Tooltip ──────────────────────────────────────────────────────────────
describe('tooltip', () => {
+ it('includes caller-owned freshness context beside unchanged disk evidence', async () => {
+ const message = 'Using last known disk stats. Guest reads paused while a backup is running.';
+ const { container } = render(() => (
+
+ ));
+ await fireEvent.mouseEnter(getBarTrigger(container));
+ expect(screen.getByText(message)).toBeInTheDocument();
+ expect(screen.getByText('/')).toBeInTheDocument();
+ expect(screen.getByText('Disk Usage')).toBeInTheDocument();
+ });
+
it('shows tooltip on mouse enter with disk details', async () => {
const disk = makeDisk({
used: 53687091200,
diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
index ed72f85037..3dbf3531b6 100644
--- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
+++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts
@@ -18,6 +18,7 @@ export interface StackedDiskBarProps {
showDiskCount?: boolean;
anomaly?: AnomalyReport | null;
thresholds?: MetricDisplayThresholds | null;
+ statusMessage?: string;
}
export interface StackedDiskSegment {
diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
index 7c7c0bd422..d7c8fbde94 100644
--- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
import {
getWorkloadsGuestBackupStatusPresentation,
getWorkloadsGuestBackupTooltip,
@@ -8,6 +9,18 @@ import {
} from '@/utils/workloadGuestPresentation';
describe('workloadGuestPresentation', () => {
+ it.each(guestDiskDeferrals)(
+ 'explains %s with and without retained disk evidence',
+ (reason, message) => {
+ expect(getWorkloadGuestDiskStatusMessage(reason)).toBe(message);
+ expect(getWorkloadGuestDiskStatusMessage(`prev-${reason}`)).toBe(
+ `Using last known disk stats. ${message}`,
+ );
+ expect(message).not.toContain('may not be installed');
+ expect(message).not.toContain('may need to be restarted');
+ },
+ );
+
it('returns canonical guest backup status presentation', () => {
expect(getWorkloadsGuestBackupStatusPresentation('fresh')).toEqual({
color: 'text-green-600 dark:text-green-400',
diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts
index 7b68c92052..62c1ebead9 100644
--- a/frontend-modern/src/utils/workloadGuestPresentation.ts
+++ b/frontend-modern/src/utils/workloadGuestPresentation.ts
@@ -103,7 +103,21 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string {
case 'agent-not-running':
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
case 'agent-timeout':
- return 'Guest agent timeout. Agent may need to be restarted.';
+ return 'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.';
+ case 'vm-locked':
+ return 'Guest reads paused while Proxmox reports a VM operation lock, such as a backup. Pulse will check again on a later poll.';
+ case 'lock-unverified':
+ return 'Guest reads deferred because Pulse cannot verify that the VM is unlocked. Pulse will check again on a later poll.';
+ case 'agent-busy':
+ return 'Guest reads deferred while an earlier guest request is still in progress. Pulse will check again on a later poll.';
+ case 'agent-cooldown':
+ return 'Guest reads paused after an earlier request did not complete reliably. Pulse will check again after the cooldown.';
+ case 'agent-response-incomplete':
+ return 'Guest reads paused because the previous response was incomplete. Pulse will check again on a later poll.';
+ case 'agent-capacity':
+ return 'Guest reads deferred because Pulse has reached its guest-read capacity. Pulse will check again on a later poll.';
+ case 'invalid-guest-key':
+ return 'Guest reads unavailable because the VM identity is invalid.';
case 'permission-denied':
return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).';
case 'agent-disabled':
@@ -116,6 +130,10 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string {
return 'Error communicating with guest agent.';
case 'no-data':
return 'No disk data available from Proxmox API.';
+ case 'vm-stopped':
+ return 'Guest filesystem stats unavailable while the VM is stopped.';
+ case 'no-status':
+ return 'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.';
default:
return 'Disk stats unavailable. Guest agent may not be installed.';
}
From a355525d57f0e0ac4badfda1d014dee156313ebb Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 15:22:59 +0100
Subject: [PATCH 11/13] Keep fresh drawer test bytes consistent with its
percentage
The shared filesystem presenter derives utilisation from used/total bytes. Set the fresh control to consistent 75 percent bytes as the browser already does; changing only usage left the test fixture at 50 percent. Runtime and parent-bound browser content are unchanged. Retain the already requested predecessor source proof separately.
Change-source: pulse-maintainer
---
.../__tests__/GuestDrawerOverview.filesystems.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
index bb27528599..e2cb4f96fb 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx
@@ -88,7 +88,7 @@ describe('GuestDrawerOverview filesystem labels', () => {
setGuest({
...guest(),
diskStatusReason: '',
- disks: guest().disks!.map((disk) => ({ ...disk, usage: 75 })),
+ disks: guest().disks!.map((disk) => ({ ...disk, used: (disk.total ?? 0) * 0.75, usage: 75 })),
});
expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument();
expect(screen.queryByText('Status')).not.toBeInTheDocument();
From 6725576c7e9c89ec1c824571e6186fea5cb80f41 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 15:32:23 +0100
Subject: [PATCH 12/13] Allow full-width guest freshness explanations in drawer
ownership guard
The full frontend run found its old global stacked-row count rejected the visually verified status explanation. Keep explicit filesystem/status placement and canonical drawer ownership, instead of coupling the guard to an unrelated global count. Mounted and browser controls still require readable status alongside retained data. No runtime or browser content changes.
Change-source: pulse-maintainer
---
.../WorkloadsSurface.performance.contract.test.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
index 2bd43bdb70..a621d42700 100644
--- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx
@@ -1661,7 +1661,11 @@ describe('Workloads performance contract', () => {
);
expect(filesystemSectionSource).toContain("layout: 'stacked'");
expect(filesystemSectionSource).toContain('wrap: true');
- expect(guestDrawerOverviewSource.match(/layout: 'stacked'/g)).toHaveLength(1);
+ // Both filesystem paths and their longer freshness explanation need
+ // the card's full width. Do not couple that layout to a global count.
+ expect(guestDrawerOverviewSource).toContain(
+ "makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true })",
+ );
expect(guestDrawerOverviewSource).toContain('TechnicalDetailsSection');
expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection');
From 11c7f32dba73d81bbbb651508d9007312c498fd4 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 16:29:54 +0100
Subject: [PATCH 13/13] Format the combined Swarm wrapping regression assertion
Keep the existing narrow-table behaviour control readable under the exact pinned Prettier 3.9.8 after composing Tailwind 4 wrapping classes. Test formatting only; no product, graph or receipt delta.
Change-source: pulse-maintainer
---
.../features/docker/__tests__/DockerNativeTables.test.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
index b7f88cf332..15bb7087d2 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx
@@ -1516,7 +1516,10 @@ describe('Docker native tables', () => {
expect(screen.getByText('2')).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', 'wrap-break-word');
+ expect(screen.getByText('Rollback started')).toHaveClass(
+ 'whitespace-normal',
+ 'wrap-break-word',
+ );
expect(
screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'),
).toContain('rollback_started');