Keep backup evidence and rollback consent truthful on narrow screens

Compose the useful Kubernetes, storage and Proxmox table improvements from four proposals against current main, preserving organisation-safe History and newer recovery/drawer work. A fresh guest snapshot no longer masquerades as the latest independent backup; controller timestamps and full disk health reasons remain reachable from phone rows.

History consent names the running version separately and declares missing restore-scope metadata rather than promising an active-data rewind. Distinguish installation-only and legacy recovery and link the stopped-service full-state instructions. Add substantive dependent contracts, connected tests and a fresh production-component desktop/phone browser receipt, including the SMART wrapping correction found during validation.

Original-PR: https://github.com/rcourtman/Pulse/pull/2417
Original-PR: https://github.com/rcourtman/Pulse/pull/2418
Original-PR: https://github.com/rcourtman/Pulse/pull/2421
Original-PR: https://github.com/rcourtman/Pulse/pull/2425
Original-Commit: 3149ec61d8
Original-Commit: c02b2d81cc
Original-Commit: d33d6a4fd3
Original-Commit: 572ea0158b
Original-Commit: c5e89849bd
Original-Commit: 3f2715e3f3
Original-Commit: 852ebdca08
Original-Commit: 083cd070fe
Co-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com>
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 07:32:20 +01:00
parent 697ad83e19
commit e899ecb0cf
56 changed files with 2338 additions and 218 deletions
@@ -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,
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Operator tables and rollback consent verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./operator-tables-consent.tsx"></script>
</body>
</html>
@@ -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> = {}): 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<string | null>(null);
const [pool, setPool] = createSignal<string | null>(null);
const [expanded, setExpanded] = createSignal(new Set<string>());
const ws = {
state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
activeAlerts: () => ({}),
connected: () => true,
initialDataReceived: () => true,
};
return (
<WebSocketContext.Provider value={ws as any}>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<ToastContainer />
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Operator tables and rollback consent</h1>
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
{['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
<button
type="button"
class="min-h-11 rounded border border-border px-3"
onClick={() => setView(name)}
>
{name}
</button>
))}
</nav>
<Show when={view() === 'Pools'}>
<section aria-label="Pool table">
<StoragePoolsTable
groupedRecords={groupStorageRecords(pools, 'none')}
groupBy="none"
expandedGroups={new Set(['All'])}
toggleGroup={() => {}}
expandedPoolId={pool()}
setExpandedPoolId={setPool}
storageGrowthBySeriesId={new Map()}
storageGrowthColumnLabel="Growth"
physicalDisks={[]}
nodeOnlineByLabel={new Map()}
highlightedRecordId={null}
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
isLoading={false}
/>
</section>
</Show>
<Show when={view() === 'Disks'}>
<section aria-label="Disk table">
<DiskList
disks={disks}
nodes={[]}
selectedNode={null}
searchTerm=""
selectedDiskId={disk()}
onSelectedDiskChange={setDisk}
/>
</section>
</Show>
<Show when={view() === 'Controllers'}>
<section aria-label="Controller table">
<KubernetesControllersTable
resources={controllers}
emptyIcon={<span />}
emptyTitle="No controllers"
emptyDescription="No controllers"
showToolbar={false}
/>
</section>
</Show>
<Show when={view() === 'Backups'}>
<section aria-label="Backup tables" class="space-y-4">
<h2>Coverage</h2>
<ProxmoxCoverageTable
rows={model.coverageRows}
hasAnyRows
emptyIcon={<span />}
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}
/>
<h2>By date</h2>
<ProxmoxRecoverableTable
artifacts={model.recoverableArtifacts}
hasAnyArtifacts
emptyIcon={<span />}
emptyTitle="No artifacts"
emptyDescription="No artifacts"
sortKey={() => 'created'}
sortDirection={() => 'desc'}
onSort={() => {}}
sizeMaxBytes={1048576}
/>
<h2>Backup servers</h2>
<ProxmoxBackupServersTable servers={[pbs]} />
<h2>Proxmox nodes</h2>
<ProxmoxNodesTable
nodes={[node]}
guests={[workload]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="No nodes"
/>
<h2>Workload row</h2>
<Table>
<TableBody>
<GuestRow
guest={
{
id: 'ct-310',
vmid: 310,
name: 'artifact-cache-310',
node: 'pve-edge',
instance: 'homelab',
status: 'running',
type: 'lxc',
cpu: 0.12,
cpus: 2,
memory: { usage: 30, total: 1e9, used: 0.3e9, free: 0.7e9 },
disk: { usage: 20, total: 1e10, used: 2e9, free: 8e9 },
uptime: 86400,
template: false,
lastBackup: 0,
tags: null,
lock: '',
lastSeen: new Date(now).toISOString(),
} as any
}
visibleColumnIds={['name', 'cpu', 'memory', 'disk', 'uptime']}
workloadTableLayoutMode={innerWidth < 440 ? 'phone' : 'compact'}
/>
</TableBody>
</Table>
</section>
</Show>
<Show when={view() === 'Consent'}>
<section aria-label="Rollback history">
<UpdateHistorySection />
</section>
</Show>
</main>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
);
}
void updateStore.checkForUpdates();
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
+75 -26
View File
@@ -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."
]
}
@@ -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<ResourceSummaryPresentationP
}
: null,
props.content !== 'overview' && docker ? dockerSection(docker) : null,
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
props.content !== 'overview'
? {
label: 'Identity',
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import {
buildKubernetesControllerSection,
buildKubernetesDetailSections,
buildKubernetesDetailsSummary,
hasKubernetesDetailSections,
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
).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' },
]);
});
});
@@ -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<DetailRow | null> };
// 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;
@@ -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
</h4>
<p class="mt-1 text-xs text-muted">
Updates applied through Pulse, with rollback to the backup taken before each one.
Updates applied through Pulse, with version rollback using a retained backup.
</p>
</div>
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
</h2>
</div>
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
<p>
This restores the binary, configuration, and data from the backup taken before the
update to {formatVersionLabel(entry().version_to)} on{' '}
{formatTimestamp(entry().timestamp)}.
<p class="text-muted">
Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.{' '}
<a
class="text-primary underline"
href={`${getShippedDocUrl('AUTO_UPDATE.md')}#manual-rollback`}
>
Read the recovery instructions
</a>
.
</p>
</div>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
@@ -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(() => <UpdateHistorySection />);
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(() => <UpdateHistorySection />);
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(() => <UpdateHistorySection />);
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<boolean>((resolve) => {
finish = resolve;
}),
);
render(() => <UpdateHistorySection />);
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());
});
});
@@ -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<DiskDetailProps> = (props) => {
historyResourceId,
attributeCards,
historyCharts,
healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -91,19 +96,32 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
return (
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
<span
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
title="Data source"
>
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
</span>
<Show when={diskData().serial}>
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
</Show>
<div class={DISK_DETAIL_HEADER_STACK_CLASS}>
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>
{diskData().model || 'Unknown Disk'}
</span>
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
<span
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
title="Data source"
>
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
</span>
<Show when={diskData().serial}>
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
</Show>
</div>
{/* The verdict the row shows as a single word, with its reason in full. */}
<div class={DISK_DETAIL_HEALTH_ROW_CLASS} data-testid="disk-detail-health">
<span class={`${DISK_DETAIL_HEALTH_LABEL_CLASS} ${healthPresentation().tone}`}>
{healthPresentation().label}
</span>
<Show when={healthPresentation().summary}>
<span class={DISK_DETAIL_HEALTH_SUMMARY_CLASS}>{healthPresentation().summary}</span>
</Show>
</div>
</div>
</div>
@@ -55,7 +55,6 @@ import {
PHYSICAL_DISK_HEADER_SIZE_CLASS,
PHYSICAL_DISK_HEADER_TEMP_CLASS,
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
PHYSICAL_DISK_HEALTH_SUMMARY_CLASS,
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
PHYSICAL_DISK_LIFE_CLASS,
@@ -72,8 +71,10 @@ import {
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
PHYSICAL_DISK_TABLE_ROW_STYLE,
getPhysicalDiskColumnWidthPercent,
getPhysicalDiskCellPaddingClass,
getPhysicalDiskColumnWidthStyle,
getPhysicalDiskEmptyStatePresentation,
getPhysicalDiskHealthCompactLabel,
getPhysicalDiskHealthStatus,
getPhysicalDiskHealthSummary,
getPhysicalDiskHostLabel,
@@ -117,11 +118,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
baseClass: string,
columnId: PhysicalDiskTableColumnId,
visibleClass: 'table-column' | 'table-cell',
) =>
`${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim();
const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({
width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`,
});
) => {
const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId)
? visibleClass
: 'hidden';
// Rendered cells and headers shed the shared primitives' desktop padding
// on the phone layouts; `<col>` elements carry no padding.
const padding =
visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : '';
return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim();
};
const columnStyle = (columnId: PhysicalDiskTableColumnId) =>
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
const { getDiskTemperatureThresholds } = useAlertsActivation();
const model = useDiskListModel({
disks: () => props.disks,
@@ -327,6 +335,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
const healthCompactLabel = createMemo(() =>
getPhysicalDiskHealthCompactLabel(status().label),
);
const healthLabelClass = () =>
`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`;
const isSelected = () => model.selectedDisk()?.id === disk.id;
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
const isSummaryHighlighted = () =>
@@ -446,15 +459,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
data-storage-column="health"
>
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
{status().label}
</span>
<Show when={healthSummary()}>
{/* 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. */}
<Show
when={healthCompactLabel() !== status().label}
fallback={
<span class={healthLabelClass()} title={healthSummary() || undefined}>
{status().label}
</span>
}
>
<span
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
title={healthSummary()}
class={`${healthLabelClass()} platform-table-label-compact`}
title={healthSummary() || undefined}
>
{healthSummary()}
{healthCompactLabel()}
</span>
<span
class={`${healthLabelClass()} platform-table-label-full`}
title={healthSummary() || undefined}
>
{status().label}
</span>
</Show>
</div>
@@ -25,6 +25,7 @@ import {
getStorageBarTooltipTitle,
getStorageBarZfsHeadingLabel,
} from '@/features/storageBackups/storageBarPresentation';
import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage';
import { useTooltip } from '@/hooks/useTooltip';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
@@ -42,13 +43,14 @@ interface EnhancedStorageBarProps {
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
const tip = useTooltip();
const alertsActivation = useAlertsActivation();
const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({
used: () => props.used,
total: () => props.total,
free: () => props.free,
zfsPool: () => props.zfsPool,
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
});
const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } =
useEnhancedStorageBarModel({
used: () => props.used,
total: () => props.total,
free: () => props.free,
zfsPool: () => props.zfsPool,
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
});
return (
<div class={STORAGE_BAR_ROOT_CLASS}>
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
}
label={
<span class={STORAGE_BAR_LABEL_WRAP_CLASS}>
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>{label()}</span>
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>
{/* The phone projection of the table container keeps the
percentage only; the used/total sublabel returns with the
wider container and always lives in the tooltip. */}
<PlatformResponsiveTableLabel compact={compactLabel()} full={label()} />
</span>
</span>
}
/>
@@ -26,6 +26,7 @@ import {
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { Resource } from '@/types/resource';
import {
getStoragePoolCellPaddingClass,
isStoragePoolColumnVisible,
type StoragePoolTableColumnId,
type StoragePoolTableLayoutMode,
@@ -62,7 +63,11 @@ interface StoragePoolRowProps {
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) =>
`${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim();
`${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${
isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'
}`
.replace(/\s+/g, ' ')
.trim();
const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
const detailControlsId = createMemo(() =>
buildSummaryDisclosureControlsId(props.summarySeriesId),
@@ -9,7 +9,7 @@ import {
} from '@/components/shared/Table';
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
import {
getStoragePoolColumnWidthPercent,
getStoragePoolColumnWidthStyle,
getStoragePoolTableColumns,
getStoragePoolTableLayoutModeForContainer,
getStorageEmptyStateMessage,
@@ -106,9 +106,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
{(column) => (
<col
class={columnClass(column.colClassName, column.id, 'table-column')}
style={{
width: `${getStoragePoolColumnWidthPercent(layoutMode(), column.id)}%`,
}}
style={getStoragePoolColumnWidthStyle(layoutMode(), column.id)}
data-storage-column={column.id}
/>
)}
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
import { createSignal, type JSX } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import { DiskDetail } from '@/components/Storage/DiskDetail';
@@ -135,6 +135,33 @@ describe('DiskDetail', () => {
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
});
it('shows the health verdict and its reason in the drawer header', () => {
const disk = buildDisk();
disk.physicalDisk!.health = 'FAILED';
render(() => <DiskDetail disk={disk} nodes={[]} />);
const health = screen.getByTestId('disk-detail-health');
expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700');
expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass(
'min-w-0',
'max-w-full',
'whitespace-normal',
'break-words',
);
});
it('keeps the drawer header to the verdict alone when the disk is healthy', () => {
const disk = buildDisk();
disk.physicalDisk!.health = 'PASSED';
render(() => <DiskDetail disk={disk} nodes={[]} />);
const health = screen.getByTestId('disk-detail-health');
expect(within(health).getByText('Healthy')).toBeInTheDocument();
expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument();
});
it('shows an explicit overview fallback when no detail readings are available', () => {
const disk = buildDisk();
delete disk.physicalDisk!.smart;
@@ -134,12 +134,18 @@ describe('DiskList', () => {
'archive',
'Cache Pool',
'Archive Pool',
'SMART failed.',
'4%',
'63°C',
]) {
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
}
// The reason is not a second truncating string in the 32px row: it rides
// on the verdict as its title and is spelled out in the disk drawer.
expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument();
expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute(
'title',
'SMART failed.',
);
expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
expect(screen.queryByText('96%')).not.toBeInTheDocument();
@@ -275,7 +281,13 @@ describe('DiskList', () => {
expect(screen.getByText('Parity')).toBeInTheDocument();
expect(screen.getByText('Tower Array')).toBeInTheDocument();
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument();
// The reason stays one hover away on the verdict rather than as a second
// truncating string in the row; the drawer spells it out.
expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument();
expect(screen.getByText('Needs Attention')).toHaveAttribute(
'title',
'Pending sectors detected.',
);
});
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
@@ -1154,10 +1154,11 @@ describe('Storage', () => {
render(() => <Storage />);
// zpool's upper-case state is presented titleized, like every other state.
await waitFor(() => {
expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0);
expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0);
});
expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute(
expect(screen.getAllByText('Degraded')[0]).toHaveAttribute(
'title',
'1 read, 2 checksum errors',
);
@@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => {
expect(result.usagePercent()).toBe(40);
expect(result.label()).toBe('40% (400 B/1000 B)');
expect(result.compactLabel()).toBe('40%');
expect(result.tooltipRows()).toEqual([
{ label: 'Used', value: '400 B' },
{ label: 'Free', value: '600 B' },
@@ -7,6 +7,7 @@ import {
} from '@/features/storageBackups/diskPresentation';
import {
getDiskDetailAttributeCards,
getDiskDetailHealthPresentation,
getDiskDetailHistoryCharts,
} from '@/features/storageBackups/diskDetailPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
@@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
);
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
const metricResourceId = createMemo(() => historyResourceId());
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
const liveIOAvailable = createMemo(() => {
@@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
historyResourceId,
attributeCards,
historyCharts,
healthPresentation,
metricResourceId,
collectionMessages,
liveIOAvailable,
@@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api';
import { getMetricColorClass } from '@/utils/metricThresholds';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import {
getStorageBarCompactLabel,
getStorageBarLabel,
getStorageBarTooltipRows,
getStorageBarUsagePercent,
@@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
);
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total()));
const tooltipRows = createMemo(() =>
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
);
@@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
usagePercent,
barColor,
label,
compactLabel,
tooltipRows,
zfsSummary,
};
@@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => {
});
it('derives mobile overrides from the canonical guest column model', () => {
// The phone name cell carries the toggle, status dot, and backup badge, so
// it takes a wider anchor than the shared 30% and the metric chips share
// the rest.
expect(getGuestColumnStyle('name', true)).toEqual({
width: '30%',
'max-width': '30%',
width: '38%',
'max-width': '38%',
});
expect(getGuestColumnStyle('cpu', true)).toEqual({
width: '11.3235%',
'max-width': '11.3235%',
width: '10.0294%',
'max-width': '10.0294%',
});
expect(getGuestColumnStyle('availability', true)).toEqual({
width: '7.2059%',
'max-width': '7.2059%',
width: '6.3824%',
'max-width': '6.3824%',
});
expect(getGuestColumnStyle('type', true)).toEqual({
width: '9.2647%',
'max-width': '9.2647%',
width: '8.2059%',
'max-width': '8.2059%',
});
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' });
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' });
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
});
@@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({
maxWidth: width,
});
// The phone name cell carries the expansion toggle, status dot, and backup
// badge before the name, so the shared 30% identity anchor left a 390px phone
// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The
// four metric chips beside it need far less than an even split, so the name
// takes the same wider phone anchor the Proxmox host table uses.
const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
// Responsive weights are normalized against the currently visible column set.
// That keeps each workload view mode full-width without assuming one fixed set.
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -548,9 +555,11 @@ const getResponsiveColumnOverride = (
activeIds,
layoutMode === 'narrow'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
: layoutMode === 'phone'
? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
};
@@ -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
</PlatformSortableTableHead>
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
>
Scope
</PlatformSortableTableHead>
{/* 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. */}
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="target"
class="platform-table-mobile-w-20 md:w-[12%]"
class="platform-table-phone-hidden md:w-[12%]"
>
Target
</PlatformSortableTableHead>
@@ -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%]"
>
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
</PlatformSortableTableHead>
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
<span class="inline-block max-w-[12rem] truncate" title={target()}>
{target()}
@@ -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(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'node-exporter',
type: 'k8s-daemonset',
kubernetes: {
clusterName: 'prod',
namespace: 'observability',
resourceKind: 'DaemonSet',
desiredNumberScheduled: 6,
numberReady: 6,
},
}),
]}
emptyIcon={<span />}
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(() => (
<KubernetesControllersTable
resources={[
makeResource({
id: 'nightly-import',
type: 'k8s-job',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'Job',
desiredReplicas: 10,
active: 1,
succeeded: 8,
failed: 2,
startTime: '2026-05-24T12:55:00Z',
completionTime: '2026-05-24T13:00:00Z',
},
}),
makeResource({
id: 'billing-rollup',
type: 'k8s-cronjob',
kubernetes: {
clusterName: 'prod',
namespace: 'batch',
resourceKind: 'CronJob',
schedule: '*/5 * * * *',
lastScheduleTime: '2026-05-24T13:25:00Z',
lastSuccessfulTime: '2026-05-24T12:55:00Z',
},
}),
]}
emptyIcon={<span />}
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')}`,
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
}
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="State" full="Status" />
</TableHead>
<Show when={columnVisible('version')}>
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
</TableHead>
</Show>
<Show when={columnVisible('datastore')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
Used
</TableHead>
<Show when={columnVisible('backups')}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
aria-label="Backups"
title="Backups"
>
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
>
{(datastore) => (
<div class="flex items-center justify-end gap-2">
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
</Show>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<PlatformTablePercentValue value={pct()} />
</span>
@@ -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':
@@ -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')}
/>
<SortableHead
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
label={
layoutMode() === 'compact'
? 'Age'
: layoutMode() === 'basic'
? 'Backup'
: 'Last backup'
}
sortKey="latest"
currentSort={props.sortKey}
direction={props.sortDirection}
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={row.latestRecovery}
when={row.latestBackup}
fallback={
<span class="text-muted" title="No restore point">
<span
class="text-muted"
title={
row.latestSnapshot
? COVERAGE_SNAPSHOT_ONLY_TITLE
: COVERAGE_NO_BACKUP_TITLE
}
>
{COVERAGE_EMPTY_LABEL}
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
<Show when={columnVisible('pbs')}>
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
</span>
}
>
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
{(artifact) => (
<ProxmoxBackupAgeText
artifact={artifact()}
compact={layoutMode() === 'compact'}
/>
)}
</Show>
</TableCell>
</Show>
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
>
<span
class="inline-block max-w-[24rem] truncate"
title={artifact.detail}
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
@@ -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: {
</TableCell>
</Show>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
<ProxmoxBackupAgeText artifact={artifact} />
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
</TableCell>
<Show when={columnVisible('size')}>
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
</TableCell>
<Show when={columnVisible('details')}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
<span
class="inline-block max-w-[20rem] truncate"
title={artifact.detailTitle ?? artifact.detail}
>
{artifact.detail || '—'}
</span>
</TableCell>
@@ -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',
@@ -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');
});
@@ -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')],
@@ -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({})],
@@ -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',
@@ -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(() => <ProxmoxBackupAgeText artifact={artifact} compact />);
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(');
@@ -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', () => {
@@ -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
@@ -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<Record<BackupServerColumnId, number>>
> = {
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<CoverageColumnId>[] =
{ 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<CoverageColumnId>[] =
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
// 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<Record<CoverageColumnId, number>>
> = {
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<ProxmoxBackupsTableLayoutMode, readonly Recove
// A recovery-point feed starts with identity, source, location, age, and
// verification. Size and verbose details progressively return with usable
// room; the full values remain available through their title affordance.
compact: ['workload', 'source', 'location', 'created', 'state'],
// The phone projection keeps the source and state badges and the age whole;
// the repository location returns with the first wider layout and stays in
// the row's hover title meanwhile.
compact: ['workload', 'source', 'created', 'state'],
basic: ['workload', 'source', 'location', 'created', 'state'],
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
expanded: ['workload', 'type', 'targetId', 'source', 'location', 'created', 'size', 'state'],
@@ -301,7 +311,7 @@ const RECOVERABLE_WEIGHTS: Record<
// Reserve enough phone-width space for complete state badges (including
// Failed and Running) rather than clipping the recovery answer at the
// scroll edge. Identity remains on the canonical 40% anchor.
compact: { workload: 40, source: 12, location: 17, created: 15, state: 16 },
compact: { workload: 40, source: 16, created: 14, state: 30 },
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
operational: {
workload: 24,
@@ -1,4 +1,4 @@
import type { Accessor } from 'solid-js';
import { Show, type Accessor } from 'solid-js';
import { filterChipStatusDot } from '@/components/shared/FilterBar';
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
import { TableHead } from '@/components/shared/Table';
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
import {
PlatformTableRelativeTimeValue,
formatPlatformTableRelativeTimeValue,
} from '@/features/platformPage/sharedPlatformPage';
import {
getRecoveryAgeBand,
@@ -219,7 +222,15 @@ export function RowMetricBar(props: {
);
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
// Phone columns are too narrow for "18h ago", so the compact form drops the
// suffix ("18h", "now") and keeps the full age band and timestamp on hover.
export function formatCompactBackupAge(createdAt: string): string {
const relative = formatPlatformTableRelativeTimeValue(createdAt);
if (relative === 'just now') return 'now';
return relative.replace(/ ago$/, '');
}
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact; compact?: boolean }) {
const band = () => 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 (
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />
<Show
when={props.compact}
fallback={<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />}
>
{formatCompactBackupAge(props.artifact.createdAt)}
</Show>
</span>
);
}
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
cpu: 'narrow',
memory: 'narrow',
disk: 'narrow',
temp: 'phone',
temp: 'mobile',
uptime: 'narrow',
cluster: 'tablet',
vms: 'compact',
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
// pill) take only what they need.
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
node: 18,
version: 7,
node: 20,
version: 6,
uptime: 7,
cpu: 13,
memory: 13,
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
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,
);
};
@@ -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({
@@ -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', () => {
@@ -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)', () => {
@@ -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', () => {
@@ -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(),
@@ -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(
@@ -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%']);
});
});
@@ -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,
@@ -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<Record<PhysicalDiskTableColumnId, number>>
> = {
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';
@@ -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 {
@@ -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';
@@ -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<Record<StoragePoolTableColumnId, number>>
> = {
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';