+
+
+
+
+
+
+
+
+
+
+ );
+}
+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}