mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
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:3149ec61d8Original-Commit:c02b2d81ccOriginal-Commit:d33d6a4fd3Original-Commit:572ea0158bOriginal-Commit:c5e89849bdOriginal-Commit:3f2715e3f3Original-Commit:852ebdca08Original-Commit:083cd070feCo-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com> Change-source: pulse-maintainer
This commit is contained in:
@@ -15,6 +15,23 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Demo transactions share the service host, not just a workflow queue
|
||||||
|
|
||||||
Stable demo update and recovery keep their existing publication/activation,
|
Stable demo update and recovery keep their existing publication/activation,
|
||||||
|
|||||||
@@ -20,6 +20,36 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Organisation-scoped single-metric History
|
||||||
|
|
||||||
Fetched HistoryChart observations belong to the active organisation as well as
|
Fetched HistoryChart observations belong to the active organisation as well as
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Bounded confidence/priority work in broadcast grouping — issue #2199
|
||||||
|
|
||||||
The general identity matcher retains every confidence, reason, ordering and
|
The general identity matcher retains every confidence, reason, ordering and
|
||||||
|
|||||||
@@ -21,6 +21,28 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
### Disk History is independent of current collection
|
||||||
|
|
||||||
The existing physical-disk History catalog is selected by disk family, not by
|
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
|
## 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
|
### Resource evidence fallback access boundaries
|
||||||
|
|
||||||
When the existing resource-facet reader receives a final HTTP 401/403, its
|
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')!,
|
||||||
|
);
|
||||||
@@ -1,23 +1,67 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
|
"base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4",
|
||||||
"verified_at": "2026-10-03T04:13:25.499572Z",
|
"verified_at": "2026-10-03T06:30:17.630790Z",
|
||||||
"changed_paths": [
|
"changed_paths": [
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
"frontend-modern/src/components/Settings/UpdateHistorySection.tsx",
|
||||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
|
"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": {
|
"content_sha256": {
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
|
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
|
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51",
|
||||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
|
"frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "39e7f5daa49bd18fd71d91a09a06da47ec3abc5496108d3715020180ca439e52",
|
||||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
|
"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": [
|
"routes": [
|
||||||
"Existing PBS/resource detail Overview change history",
|
"Existing storage pool and physical-disk tables / disk expansion",
|
||||||
"Existing resource detail Manage action history"
|
"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": [
|
"viewports": [
|
||||||
{
|
{
|
||||||
@@ -27,26 +71,31 @@
|
|||||||
{
|
{
|
||||||
"width": 390,
|
"width": 390,
|
||||||
"height": 844
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 768,
|
||||||
|
"height": 900
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
|
"Completed backup stays 13d old alongside a separate fresh 30m guest snapshot; missing independent backup remains unit-covered",
|
||||||
"Denied filtered facets do not fall back to the unfiltered bundle",
|
"Structured ZFS scrub/resilver activity badges render at desktop/intermediate width; phone retains Degraded/Online and measured 59% capacity",
|
||||||
"Retry retains denial while pending; fresh successful response restores events and clears guidance",
|
"Disk table verdict keeps full SMART reason in the expanded header without phone overflow",
|
||||||
"First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
|
"Job and CronJob absolute timestamps, elapsed duration, target, schedule, suspension and last-success remain reachable from phone row expansion",
|
||||||
"Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
|
"Consent names running and selected historical versions separately and declares absent restore scope; legacy and installation-only effects are distinguished",
|
||||||
"All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
|
"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": [
|
"interactions": [
|
||||||
"Remount full production resource drawer with denied/transient reads",
|
"Expand/collapse real resource and disk rows",
|
||||||
"Activate scoped Retry with keyboard Enter",
|
"Open consent using keyboard Enter, cancel and return focus without a rollback write",
|
||||||
"Apply and clear Change kind filter",
|
"Reject one synthetic rollback POST, then hold/accept a second while checking disabled duplicate/cancel and Escape behaviour",
|
||||||
"Switch between Overview and Manage"
|
"Select existing backup projections with independent archive and guest snapshot dates"
|
||||||
],
|
],
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
|
"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.",
|
||||||
"401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
|
"The History API exposes no restore-scope metadata; no version heuristic or free-form note establishes scope. The UI conservatively says it is unknown.",
|
||||||
"Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
|
"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.",
|
||||||
"Independent reader authorisation remains independent; this is not a global session revocation broadcast."
|
"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 { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||||
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
|
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
|
||||||
import { formatInteger } from './resourceDetailMappers';
|
import { formatInteger } from './resourceDetailMappers';
|
||||||
|
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
|
||||||
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
||||||
|
|
||||||
interface ResourceSummaryPresentationProps {
|
interface ResourceSummaryPresentationProps {
|
||||||
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
props.content !== 'overview' && docker ? dockerSection(docker) : null,
|
props.content !== 'overview' && docker ? dockerSection(docker) : null,
|
||||||
|
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
|
||||||
props.content !== 'overview'
|
props.content !== 'overview'
|
||||||
? {
|
? {
|
||||||
label: 'Identity',
|
label: 'Identity',
|
||||||
|
|||||||
+203
-1
@@ -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 type { Resource } from '@/types/resource';
|
||||||
import {
|
import {
|
||||||
|
buildKubernetesControllerSection,
|
||||||
buildKubernetesDetailSections,
|
buildKubernetesDetailSections,
|
||||||
buildKubernetesDetailsSummary,
|
buildKubernetesDetailsSummary,
|
||||||
hasKubernetesDetailSections,
|
hasKubernetesDetailSections,
|
||||||
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
|
|||||||
).toBe('https://prod.k8s.local:6443');
|
).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,
|
compactDetailRows as compactRows,
|
||||||
compactDetailSections as compactSections,
|
compactDetailSections as compactSections,
|
||||||
formatDetailBytesValue,
|
formatDetailBytesValue,
|
||||||
|
formatDetailCountValue,
|
||||||
|
formatDetailIntegerValue,
|
||||||
makeDetailRow as makeRow,
|
makeDetailRow as makeRow,
|
||||||
|
type DetailRow,
|
||||||
type DetailSection,
|
type DetailSection,
|
||||||
|
type DetailValueTone,
|
||||||
} from '@/components/shared/detailSectionModel';
|
} from '@/components/shared/detailSectionModel';
|
||||||
|
import {
|
||||||
|
formatPlatformTableDateTimeValue,
|
||||||
|
formatPlatformTableDurationValue,
|
||||||
|
formatPlatformTableRelativeTimeValue,
|
||||||
|
} from '@/features/platformPage/sharedPlatformPage';
|
||||||
|
|
||||||
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
||||||
|
|
||||||
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
|
|||||||
return compactSections(sections);
|
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 => {
|
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
|
||||||
const k = resource.kubernetes;
|
const k = resource.kubernetes;
|
||||||
if (!k) return null;
|
if (!k) return null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates';
|
|||||||
import { Button } from '@/components/shared/Button';
|
import { Button } from '@/components/shared/Button';
|
||||||
import { Dialog } from '@/components/shared/Dialog';
|
import { Dialog } from '@/components/shared/Dialog';
|
||||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||||
|
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||||
|
|
||||||
const HISTORY_LIMIT = 20;
|
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
|
// 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.
|
// clears backup_path when retention prunes it) and the update actually landed.
|
||||||
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
|
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
|
||||||
@@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => {
|
|||||||
Update History
|
Update History
|
||||||
</h4>
|
</h4>
|
||||||
<p class="mt-1 text-xs text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
|
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
|
||||||
<p>
|
<p class="text-muted">
|
||||||
This restores the binary, configuration, and data from the backup taken before the
|
Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
|
||||||
update to {formatVersionLabel(entry().version_to)} on{' '}
|
|
||||||
{formatTimestamp(entry().timestamp)}.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Settings and alert changes made since that backup will be reverted, and Pulse will
|
This requests an installation rollback using the backup taken before the update to{' '}
|
||||||
restart to complete the rollback.
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
|
<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 mockListUpdateHistory = vi.fn();
|
||||||
const mockStoreRollbackUpdate = vi.fn();
|
const mockStoreRollbackUpdate = vi.fn();
|
||||||
|
let runningVersion: string | undefined = '6.0.5';
|
||||||
|
|
||||||
vi.mock('@/api/updates', () => ({
|
vi.mock('@/api/updates', () => ({
|
||||||
UpdatesAPI: {
|
UpdatesAPI: {
|
||||||
@@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({
|
|||||||
|
|
||||||
vi.mock('@/stores/updates', () => ({
|
vi.mock('@/stores/updates', () => ({
|
||||||
updateStore: {
|
updateStore: {
|
||||||
versionInfo: () => ({ version: '6.0.5' }),
|
versionInfo: () => (runningVersion ? { version: runningVersion } : null),
|
||||||
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
|
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockListUpdateHistory.mockReset();
|
mockListUpdateHistory.mockReset();
|
||||||
mockStoreRollbackUpdate.mockReset();
|
mockStoreRollbackUpdate.mockReset();
|
||||||
|
runningVersion = '6.0.5';
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => {
|
|||||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
|
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' }));
|
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(),
|
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,
|
resolveHistoryRangeWithinLimit,
|
||||||
} from '@/components/Storage/historyRangeAccess';
|
} from '@/components/Storage/historyRangeAccess';
|
||||||
import {
|
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_HISTORY_RANGE_OPTIONS,
|
||||||
DISK_DETAIL_LIVE_CHARTS,
|
DISK_DETAIL_LIVE_CHARTS,
|
||||||
getDiskAttributeValueTextClass,
|
getDiskAttributeValueTextClass,
|
||||||
@@ -55,6 +59,7 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
|||||||
historyResourceId,
|
historyResourceId,
|
||||||
attributeCards,
|
attributeCards,
|
||||||
historyCharts,
|
historyCharts,
|
||||||
|
healthPresentation,
|
||||||
metricResourceId,
|
metricResourceId,
|
||||||
collectionMessages,
|
collectionMessages,
|
||||||
liveIOAvailable,
|
liveIOAvailable,
|
||||||
@@ -91,19 +96,32 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
|
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
|
||||||
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
|
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
|
||||||
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
<div class={DISK_DETAIL_HEADER_STACK_CLASS}>
|
||||||
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
|
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
||||||
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>
|
||||||
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
{diskData().model || 'Unknown Disk'}
|
||||||
<span
|
</span>
|
||||||
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
||||||
title="Data source"
|
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
||||||
>
|
<span
|
||||||
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
||||||
</span>
|
title="Data source"
|
||||||
<Show when={diskData().serial}>
|
>
|
||||||
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
|
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
||||||
</Show>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
PHYSICAL_DISK_HEADER_SIZE_CLASS,
|
PHYSICAL_DISK_HEADER_SIZE_CLASS,
|
||||||
PHYSICAL_DISK_HEADER_TEMP_CLASS,
|
PHYSICAL_DISK_HEADER_TEMP_CLASS,
|
||||||
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
|
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
|
||||||
PHYSICAL_DISK_HEALTH_SUMMARY_CLASS,
|
|
||||||
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
|
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
|
||||||
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
|
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
|
||||||
PHYSICAL_DISK_LIFE_CLASS,
|
PHYSICAL_DISK_LIFE_CLASS,
|
||||||
@@ -72,8 +71,10 @@ import {
|
|||||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||||
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
|
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
|
||||||
PHYSICAL_DISK_TABLE_ROW_STYLE,
|
PHYSICAL_DISK_TABLE_ROW_STYLE,
|
||||||
getPhysicalDiskColumnWidthPercent,
|
getPhysicalDiskCellPaddingClass,
|
||||||
|
getPhysicalDiskColumnWidthStyle,
|
||||||
getPhysicalDiskEmptyStatePresentation,
|
getPhysicalDiskEmptyStatePresentation,
|
||||||
|
getPhysicalDiskHealthCompactLabel,
|
||||||
getPhysicalDiskHealthStatus,
|
getPhysicalDiskHealthStatus,
|
||||||
getPhysicalDiskHealthSummary,
|
getPhysicalDiskHealthSummary,
|
||||||
getPhysicalDiskHostLabel,
|
getPhysicalDiskHostLabel,
|
||||||
@@ -117,11 +118,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
|||||||
baseClass: string,
|
baseClass: string,
|
||||||
columnId: PhysicalDiskTableColumnId,
|
columnId: PhysicalDiskTableColumnId,
|
||||||
visibleClass: 'table-column' | 'table-cell',
|
visibleClass: 'table-column' | 'table-cell',
|
||||||
) =>
|
) => {
|
||||||
`${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim();
|
const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId)
|
||||||
const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({
|
? visibleClass
|
||||||
width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`,
|
: '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 { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||||
const model = useDiskListModel({
|
const model = useDiskListModel({
|
||||||
disks: () => props.disks,
|
disks: () => props.disks,
|
||||||
@@ -327,6 +335,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
|||||||
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
|
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
|
||||||
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
|
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
|
||||||
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
|
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 isSelected = () => model.selectedDisk()?.id === disk.id;
|
||||||
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
|
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
|
||||||
const isSummaryHighlighted = () =>
|
const isSummaryHighlighted = () =>
|
||||||
@@ -446,15 +459,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
|||||||
data-storage-column="health"
|
data-storage-column="health"
|
||||||
>
|
>
|
||||||
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
||||||
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
|
{/* The row shows the verdict word only; the reason is the
|
||||||
{status().label}
|
cell's title here and is spelled out in the disk drawer.
|
||||||
</span>
|
The phone projection of the table container shows the
|
||||||
<Show when={healthSummary()}>
|
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
|
<span
|
||||||
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
|
class={`${healthLabelClass()} platform-table-label-compact`}
|
||||||
title={healthSummary()}
|
title={healthSummary() || undefined}
|
||||||
>
|
>
|
||||||
{healthSummary()}
|
{healthCompactLabel()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class={`${healthLabelClass()} platform-table-label-full`}
|
||||||
|
title={healthSummary() || undefined}
|
||||||
|
>
|
||||||
|
{status().label}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
getStorageBarTooltipTitle,
|
getStorageBarTooltipTitle,
|
||||||
getStorageBarZfsHeadingLabel,
|
getStorageBarZfsHeadingLabel,
|
||||||
} from '@/features/storageBackups/storageBarPresentation';
|
} from '@/features/storageBackups/storageBarPresentation';
|
||||||
|
import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage';
|
||||||
import { useTooltip } from '@/hooks/useTooltip';
|
import { useTooltip } from '@/hooks/useTooltip';
|
||||||
import { ProgressBar } from '@/components/shared/ProgressBar';
|
import { ProgressBar } from '@/components/shared/ProgressBar';
|
||||||
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
||||||
@@ -42,13 +43,14 @@ interface EnhancedStorageBarProps {
|
|||||||
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
||||||
const tip = useTooltip();
|
const tip = useTooltip();
|
||||||
const alertsActivation = useAlertsActivation();
|
const alertsActivation = useAlertsActivation();
|
||||||
const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({
|
const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } =
|
||||||
used: () => props.used,
|
useEnhancedStorageBarModel({
|
||||||
total: () => props.total,
|
used: () => props.used,
|
||||||
free: () => props.free,
|
total: () => props.total,
|
||||||
zfsPool: () => props.zfsPool,
|
free: () => props.free,
|
||||||
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
zfsPool: () => props.zfsPool,
|
||||||
});
|
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={STORAGE_BAR_ROOT_CLASS}>
|
<div class={STORAGE_BAR_ROOT_CLASS}>
|
||||||
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
|||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
<span class={STORAGE_BAR_LABEL_WRAP_CLASS}>
|
<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>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import {
|
import {
|
||||||
|
getStoragePoolCellPaddingClass,
|
||||||
isStoragePoolColumnVisible,
|
isStoragePoolColumnVisible,
|
||||||
type StoragePoolTableColumnId,
|
type StoragePoolTableColumnId,
|
||||||
type StoragePoolTableLayoutMode,
|
type StoragePoolTableLayoutMode,
|
||||||
@@ -62,7 +63,11 @@ interface StoragePoolRowProps {
|
|||||||
|
|
||||||
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
||||||
const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) =>
|
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 row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
|
||||||
const detailControlsId = createMemo(() =>
|
const detailControlsId = createMemo(() =>
|
||||||
buildSummaryDisclosureControlsId(props.summarySeriesId),
|
buildSummaryDisclosureControlsId(props.summarySeriesId),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@/components/shared/Table';
|
} from '@/components/shared/Table';
|
||||||
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
|
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
|
||||||
import {
|
import {
|
||||||
getStoragePoolColumnWidthPercent,
|
getStoragePoolColumnWidthStyle,
|
||||||
getStoragePoolTableColumns,
|
getStoragePoolTableColumns,
|
||||||
getStoragePoolTableLayoutModeForContainer,
|
getStoragePoolTableLayoutModeForContainer,
|
||||||
getStorageEmptyStateMessage,
|
getStorageEmptyStateMessage,
|
||||||
@@ -106,9 +106,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
|
|||||||
{(column) => (
|
{(column) => (
|
||||||
<col
|
<col
|
||||||
class={columnClass(column.colClassName, column.id, 'table-column')}
|
class={columnClass(column.colClassName, column.id, 'table-column')}
|
||||||
style={{
|
style={getStoragePoolColumnWidthStyle(layoutMode(), column.id)}
|
||||||
width: `${getStoragePoolColumnWidthPercent(layoutMode(), column.id)}%`,
|
|
||||||
}}
|
|
||||||
data-storage-column={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 { createSignal, type JSX } from 'solid-js';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { DiskDetail } from '@/components/Storage/DiskDetail';
|
import { DiskDetail } from '@/components/Storage/DiskDetail';
|
||||||
@@ -135,6 +135,33 @@ describe('DiskDetail', () => {
|
|||||||
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
|
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', () => {
|
it('shows an explicit overview fallback when no detail readings are available', () => {
|
||||||
const disk = buildDisk();
|
const disk = buildDisk();
|
||||||
delete disk.physicalDisk!.smart;
|
delete disk.physicalDisk!.smart;
|
||||||
|
|||||||
@@ -134,12 +134,18 @@ describe('DiskList', () => {
|
|||||||
'archive',
|
'archive',
|
||||||
'Cache Pool',
|
'Cache Pool',
|
||||||
'Archive Pool',
|
'Archive Pool',
|
||||||
'SMART failed.',
|
|
||||||
'4%',
|
'4%',
|
||||||
'63°C',
|
'63°C',
|
||||||
]) {
|
]) {
|
||||||
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
|
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(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('96%')).not.toBeInTheDocument();
|
expect(screen.queryByText('96%')).not.toBeInTheDocument();
|
||||||
@@ -275,7 +281,13 @@ describe('DiskList', () => {
|
|||||||
expect(screen.getByText('Parity')).toBeInTheDocument();
|
expect(screen.getByText('Parity')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Tower Array')).toBeInTheDocument();
|
expect(screen.getByText('Tower Array')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Needs Attention')).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', () => {
|
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
|
||||||
|
|||||||
@@ -1154,10 +1154,11 @@ describe('Storage', () => {
|
|||||||
|
|
||||||
render(() => <Storage />);
|
render(() => <Storage />);
|
||||||
|
|
||||||
|
// zpool's upper-case state is presented titleized, like every other state.
|
||||||
await waitFor(() => {
|
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',
|
'title',
|
||||||
'1 read, 2 checksum errors',
|
'1 read, 2 checksum errors',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => {
|
|||||||
|
|
||||||
expect(result.usagePercent()).toBe(40);
|
expect(result.usagePercent()).toBe(40);
|
||||||
expect(result.label()).toBe('40% (400 B/1000 B)');
|
expect(result.label()).toBe('40% (400 B/1000 B)');
|
||||||
|
expect(result.compactLabel()).toBe('40%');
|
||||||
expect(result.tooltipRows()).toEqual([
|
expect(result.tooltipRows()).toEqual([
|
||||||
{ label: 'Used', value: '400 B' },
|
{ label: 'Used', value: '400 B' },
|
||||||
{ label: 'Free', value: '600 B' },
|
{ label: 'Free', value: '600 B' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@/features/storageBackups/diskPresentation';
|
} from '@/features/storageBackups/diskPresentation';
|
||||||
import {
|
import {
|
||||||
getDiskDetailAttributeCards,
|
getDiskDetailAttributeCards,
|
||||||
|
getDiskDetailHealthPresentation,
|
||||||
getDiskDetailHistoryCharts,
|
getDiskDetailHistoryCharts,
|
||||||
} from '@/features/storageBackups/diskDetailPresentation';
|
} from '@/features/storageBackups/diskDetailPresentation';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
@@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
|||||||
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
|
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
|
||||||
);
|
);
|
||||||
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
|
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
|
||||||
|
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
|
||||||
const metricResourceId = createMemo(() => historyResourceId());
|
const metricResourceId = createMemo(() => historyResourceId());
|
||||||
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
|
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
|
||||||
const liveIOAvailable = createMemo(() => {
|
const liveIOAvailable = createMemo(() => {
|
||||||
@@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
|||||||
historyResourceId,
|
historyResourceId,
|
||||||
attributeCards,
|
attributeCards,
|
||||||
historyCharts,
|
historyCharts,
|
||||||
|
healthPresentation,
|
||||||
metricResourceId,
|
metricResourceId,
|
||||||
collectionMessages,
|
collectionMessages,
|
||||||
liveIOAvailable,
|
liveIOAvailable,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api';
|
|||||||
import { getMetricColorClass } from '@/utils/metricThresholds';
|
import { getMetricColorClass } from '@/utils/metricThresholds';
|
||||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||||
import {
|
import {
|
||||||
|
getStorageBarCompactLabel,
|
||||||
getStorageBarLabel,
|
getStorageBarLabel,
|
||||||
getStorageBarTooltipRows,
|
getStorageBarTooltipRows,
|
||||||
getStorageBarUsagePercent,
|
getStorageBarUsagePercent,
|
||||||
@@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
|||||||
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
|
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
|
||||||
);
|
);
|
||||||
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
|
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
|
||||||
|
const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total()));
|
||||||
const tooltipRows = createMemo(() =>
|
const tooltipRows = createMemo(() =>
|
||||||
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
|
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
|
||||||
);
|
);
|
||||||
@@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
|||||||
usagePercent,
|
usagePercent,
|
||||||
barColor,
|
barColor,
|
||||||
label,
|
label,
|
||||||
|
compactLabel,
|
||||||
tooltipRows,
|
tooltipRows,
|
||||||
zfsSummary,
|
zfsSummary,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('derives mobile overrides from the canonical guest column model', () => {
|
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({
|
expect(getGuestColumnStyle('name', true)).toEqual({
|
||||||
width: '30%',
|
width: '38%',
|
||||||
'max-width': '30%',
|
'max-width': '38%',
|
||||||
});
|
});
|
||||||
expect(getGuestColumnStyle('cpu', true)).toEqual({
|
expect(getGuestColumnStyle('cpu', true)).toEqual({
|
||||||
width: '11.3235%',
|
width: '10.0294%',
|
||||||
'max-width': '11.3235%',
|
'max-width': '10.0294%',
|
||||||
});
|
});
|
||||||
expect(getGuestColumnStyle('availability', true)).toEqual({
|
expect(getGuestColumnStyle('availability', true)).toEqual({
|
||||||
width: '7.2059%',
|
width: '6.3824%',
|
||||||
'max-width': '7.2059%',
|
'max-width': '6.3824%',
|
||||||
});
|
});
|
||||||
expect(getGuestColumnStyle('type', true)).toEqual({
|
expect(getGuestColumnStyle('type', true)).toEqual({
|
||||||
width: '9.2647%',
|
width: '8.2059%',
|
||||||
'max-width': '9.2647%',
|
'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' });
|
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({
|
|||||||
maxWidth: width,
|
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.
|
// Responsive weights are normalized against the currently visible column set.
|
||||||
// That keeps each workload view mode full-width without assuming one fixed set.
|
// That keeps each workload view mode full-width without assuming one fixed set.
|
||||||
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||||
@@ -548,9 +555,11 @@ const getResponsiveColumnOverride = (
|
|||||||
activeIds,
|
activeIds,
|
||||||
layoutMode === 'narrow'
|
layoutMode === 'narrow'
|
||||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
: layoutMode === 'phone'
|
||||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||||
: undefined,
|
: layoutMode === 'mobile'
|
||||||
|
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
|
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
formatPlatformTableDateTimeValue,
|
formatPlatformTableDateTimeValue,
|
||||||
formatPlatformTableRelativeTimeValue,
|
formatPlatformTableRelativeTimeValue,
|
||||||
formatPlatformTableTextValue,
|
formatPlatformTableTextValue,
|
||||||
|
getPlatformResourceCountNoun,
|
||||||
getPlatformTableCellClassForKind,
|
getPlatformTableCellClassForKind,
|
||||||
PlatformTableShell,
|
PlatformTableShell,
|
||||||
type PlatformTableSortValue,
|
type PlatformTableSortValue,
|
||||||
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
|
|||||||
const controllerKind = (resource: Resource): string =>
|
const controllerKind = (resource: Resource): string =>
|
||||||
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
|
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 => {
|
const targetValue = (resource: Resource): string => {
|
||||||
switch (resource.type) {
|
switch (resource.type) {
|
||||||
case 'k8s-replicaset':
|
case 'k8s-replicaset':
|
||||||
case 'k8s-statefulset':
|
case 'k8s-statefulset':
|
||||||
return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
|
return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
|
||||||
case 'k8s-daemonset':
|
case 'k8s-daemonset':
|
||||||
return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
|
return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
|
||||||
case 'k8s-job':
|
case 'k8s-job':
|
||||||
return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
|
return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
|
||||||
case 'k8s-cronjob':
|
case 'k8s-cronjob':
|
||||||
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
|
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
|
||||||
default:
|
default:
|
||||||
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
|
|||||||
kind="text"
|
kind="text"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="kind"
|
sortKey="kind"
|
||||||
class="platform-table-mobile-w-15 md:w-[8%]"
|
class="platform-table-mobile-w-25 md:w-[8%]"
|
||||||
>
|
>
|
||||||
Kind
|
Kind
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
|
|||||||
>
|
>
|
||||||
Scope
|
Scope
|
||||||
</PlatformSortableTableHead>
|
</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
|
<PlatformSortableTableHead
|
||||||
kind="text"
|
kind="text"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="target"
|
sortKey="target"
|
||||||
class="platform-table-mobile-w-20 md:w-[12%]"
|
class="platform-table-phone-hidden md:w-[12%]"
|
||||||
>
|
>
|
||||||
Target
|
Target
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
|
|||||||
kind="text"
|
kind="text"
|
||||||
sort={sort}
|
sort={sort}
|
||||||
sortKey="exceptions"
|
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" />
|
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
|
||||||
</PlatformSortableTableHead>
|
</PlatformSortableTableHead>
|
||||||
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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()}>
|
<span class="inline-block max-w-[12rem] truncate" title={target()}>
|
||||||
{target()}
|
{target()}
|
||||||
|
|||||||
+125
-1
@@ -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';
|
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 { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import { KubernetesControllersTable } from '../KubernetesControllersTable';
|
import { KubernetesControllersTable } from '../KubernetesControllersTable';
|
||||||
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
|
|||||||
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
|
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', () => {
|
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
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(
|
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
|
||||||
'title',
|
'title',
|
||||||
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
|
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
|
||||||
|
|||||||
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
|
|||||||
}
|
}
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<TableHead
|
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
|
||||||
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
|
|
||||||
>
|
|
||||||
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
|
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
|
|
||||||
>
|
|
||||||
<PlatformResponsiveTableLabel compact="State" full="Status" />
|
<PlatformResponsiveTableLabel compact="State" full="Status" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<Show when={columnVisible('version')}>
|
<Show when={columnVisible('version')}>
|
||||||
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={columnVisible('datastore')}>
|
<Show when={columnVisible('datastore')}>
|
||||||
<TableHead
|
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
|
|
||||||
>
|
|
||||||
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
|
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</Show>
|
</Show>
|
||||||
<TableHead
|
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
|
||||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
|
|
||||||
>
|
|
||||||
Used
|
Used
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<Show when={columnVisible('backups')}>
|
<Show when={columnVisible('backups')}>
|
||||||
<TableHead
|
<TableHead
|
||||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
|
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
|
||||||
aria-label="Backups"
|
aria-label="Backups"
|
||||||
title="Backups"
|
title="Backups"
|
||||||
>
|
>
|
||||||
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
|
|||||||
>
|
>
|
||||||
{(datastore) => (
|
{(datastore) => (
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<StatusDot
|
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
|
||||||
size="sm"
|
<StatusDot
|
||||||
variant={usageVariant(pct())}
|
size="sm"
|
||||||
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
variant={usageVariant(pct())}
|
||||||
ariaHidden
|
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
||||||
/>
|
ariaHidden
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
|
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
|
||||||
<PlatformTablePercentValue value={pct()} />
|
<PlatformTablePercentValue value={pct()} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildProxmoxBackupRecoveryModel,
|
buildProxmoxBackupRecoveryModel,
|
||||||
coverageRowMatchesSearch,
|
coverageRowMatchesSearch,
|
||||||
|
isBackupArtifact,
|
||||||
recoverableArtifactMatchesSearch,
|
recoverableArtifactMatchesSearch,
|
||||||
type RecoverableArtifact,
|
type RecoverableArtifact,
|
||||||
type WorkloadCoverageRow,
|
type WorkloadCoverageRow,
|
||||||
@@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{
|
|||||||
return recoveryModel().coverageRows.flatMap((row) => {
|
return recoveryModel().coverageRows.flatMap((row) => {
|
||||||
const artifacts = row.artifacts.filter(locationMatches);
|
const artifacts = row.artifacts.filter(locationMatches);
|
||||||
if (artifacts.length === 0) return [];
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
...row,
|
...row,
|
||||||
artifacts,
|
artifacts,
|
||||||
latestRecovery: newestArtifact(artifacts),
|
latestBackup: newestArtifact(completed.filter(isBackupArtifact)),
|
||||||
latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
|
latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')),
|
||||||
latestArchive: newestArtifact(
|
latestArchive: newestArtifact(
|
||||||
artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
|
completed.filter((artifact) => artifact.sourceKind === 'archive'),
|
||||||
),
|
),
|
||||||
latestSnapshot: newestArtifact(
|
latestSnapshot: newestArtifact(
|
||||||
artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||||
),
|
),
|
||||||
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
|
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
|
||||||
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
|
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
|
||||||
@@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{
|
|||||||
case 'workload':
|
case 'workload':
|
||||||
return cmpString(a.workload.label, b.workload.label, direction);
|
return cmpString(a.workload.label, b.workload.label, direction);
|
||||||
case 'latest':
|
case 'latest':
|
||||||
return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction);
|
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
|
||||||
case 'pbs':
|
case 'pbs':
|
||||||
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
|
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
|
||||||
case 'archive':
|
case 'archive':
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing
|
|||||||
// "None" instead of restating the source ("No PVE backup file"). The long form
|
// "None" instead of restating the source ("No PVE backup file"). The long form
|
||||||
// truncated at most widths and stays available as the hover title.
|
// truncated at most widths and stays available as the hover title.
|
||||||
const COVERAGE_EMPTY_LABEL = 'None';
|
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 = (
|
const coveragePostureVariant = (
|
||||||
posture: WorkloadCoverageRow['posture'],
|
posture: WorkloadCoverageRow['posture'],
|
||||||
@@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: {
|
|||||||
headClass={getPlatformTableHeadClassForKind('text')}
|
headClass={getPlatformTableHeadClassForKind('text')}
|
||||||
/>
|
/>
|
||||||
<SortableHead
|
<SortableHead
|
||||||
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
|
label={
|
||||||
|
layoutMode() === 'compact'
|
||||||
|
? 'Age'
|
||||||
|
: layoutMode() === 'basic'
|
||||||
|
? 'Backup'
|
||||||
|
: 'Last backup'
|
||||||
|
}
|
||||||
sortKey="latest"
|
sortKey="latest"
|
||||||
currentSort={props.sortKey}
|
currentSort={props.sortKey}
|
||||||
direction={props.sortDirection}
|
direction={props.sortDirection}
|
||||||
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
|
|||||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={row.latestRecovery}
|
when={row.latestBackup}
|
||||||
fallback={
|
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}
|
{COVERAGE_EMPTY_LABEL}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
{(artifact) => (
|
||||||
|
<ProxmoxBackupAgeText
|
||||||
|
artifact={artifact()}
|
||||||
|
compact={layoutMode() === 'compact'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<Show when={columnVisible('pbs')}>
|
<Show when={columnVisible('pbs')}>
|
||||||
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
{(artifact) => (
|
||||||
|
<ProxmoxBackupAgeText
|
||||||
|
artifact={artifact()}
|
||||||
|
compact={layoutMode() === 'compact'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-block max-w-[24rem] truncate"
|
class="inline-block max-w-[24rem] truncate"
|
||||||
title={artifact.detail}
|
title={artifact.detailTitle ?? artifact.detail}
|
||||||
>
|
>
|
||||||
{artifact.detail || '—'}
|
{artifact.detail || '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: {
|
|||||||
artifact.workload.name || artifact.workload.label,
|
artifact.workload.name || artifact.workload.label,
|
||||||
artifact.workload.typeLabel,
|
artifact.workload.typeLabel,
|
||||||
artifact.workload.vmid,
|
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)
|
.filter(Boolean)
|
||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
@@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</Show>
|
</Show>
|
||||||
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
|
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
|
||||||
<ProxmoxBackupAgeText artifact={artifact} />
|
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<Show when={columnVisible('size')}>
|
<Show when={columnVisible('size')}>
|
||||||
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
|
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
|
||||||
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<Show when={columnVisible('details')}>
|
<Show when={columnVisible('details')}>
|
||||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
<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 || '—'}
|
{artifact.detail || '—'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
|||||||
expect(headers).toContain('Target ID');
|
expect(headers).toContain('Target ID');
|
||||||
expect(headers).toContain('Node');
|
expect(headers).toContain('Node');
|
||||||
expect(headers).toContain('Posture▲');
|
expect(headers).toContain('Posture▲');
|
||||||
expect(headers).toContain('Restore');
|
expect(headers).toContain('Last backup');
|
||||||
expect(headers).toContain('PBS snapshot');
|
expect(headers).toContain('PBS snapshot');
|
||||||
expect(headers).toContain('Guest snapshot');
|
expect(headers).toContain('Guest snapshot');
|
||||||
expect(headers).not.toContain('PVE file');
|
expect(headers).not.toContain('PVE file');
|
||||||
@@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
|||||||
// cannot truncate to "No PVE backup f…".
|
// cannot truncate to "No PVE backup f…".
|
||||||
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
|
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
|
||||||
for (const title of [
|
for (const title of [
|
||||||
'No restore point',
|
'No PBS snapshot or PVE backup file',
|
||||||
'No PBS snapshot',
|
'No PBS snapshot',
|
||||||
'No PVE backup file',
|
'No PVE backup file',
|
||||||
'No guest snapshot',
|
'No guest snapshot',
|
||||||
|
|||||||
@@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => {
|
|||||||
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
|
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
|
||||||
'Workload',
|
'Workload',
|
||||||
'Via',
|
'Via',
|
||||||
'Loc',
|
|
||||||
'Age▼',
|
'Age▼',
|
||||||
'State',
|
'State',
|
||||||
]);
|
]);
|
||||||
expect(document.body.textContent).toContain('VM 100');
|
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');
|
expect(document.body.textContent).not.toContain('5 PBS files');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
|
|||||||
expect(model.coverageSummary.attention).toBe(0);
|
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([
|
const postures = new Map([
|
||||||
['vm-830', posture('vm-830', 'unprotected')],
|
['vm-830', posture('vm-830', 'unprotected')],
|
||||||
['vm-840', posture('vm-840', 'protected')],
|
['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', () => {
|
it('keeps a terminally incomplete PBS artifact out of latest recovery', () => {
|
||||||
const model = buildProxmoxBackupRecoveryModel({
|
const model = buildProxmoxBackupRecoveryModel({
|
||||||
workloads: [workload({})],
|
workloads: [workload({})],
|
||||||
@@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => {
|
|||||||
|
|
||||||
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
|
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
|
||||||
expect(failed).toMatchObject({ failed: true, running: false, verified: undefined });
|
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);
|
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', () => {
|
it('uses canonical workload attention while retaining the failed task as evidence', () => {
|
||||||
const model = buildProxmoxBackupRecoveryModel({
|
const model = buildProxmoxBackupRecoveryModel({
|
||||||
workloads: [workload({})],
|
workloads: [workload({})],
|
||||||
|
|||||||
+3
-4
@@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => {
|
|||||||
'status',
|
'status',
|
||||||
'datastore',
|
'datastore',
|
||||||
'used',
|
'used',
|
||||||
'backups',
|
|
||||||
]);
|
]);
|
||||||
expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' });
|
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([
|
expect(ids(getBackupServerColumns('basic'))).toEqual([
|
||||||
'server',
|
'server',
|
||||||
'status',
|
'status',
|
||||||
@@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => {
|
|||||||
expect(ids(getRecoverableColumns('compact'))).toEqual([
|
expect(ids(getRecoverableColumns('compact'))).toEqual([
|
||||||
'workload',
|
'workload',
|
||||||
'source',
|
'source',
|
||||||
'location',
|
|
||||||
'created',
|
'created',
|
||||||
'state',
|
'state',
|
||||||
]);
|
]);
|
||||||
expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' });
|
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([
|
expect(ids(getRecoverableColumns('basic'))).toEqual([
|
||||||
'workload',
|
'workload',
|
||||||
'source',
|
'source',
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra
|
|||||||
import {
|
import {
|
||||||
ArtifactSourceBadge,
|
ArtifactSourceBadge,
|
||||||
ArtifactStateBadge,
|
ArtifactStateBadge,
|
||||||
|
ProxmoxBackupAgeText,
|
||||||
ProxmoxBackupWorkloadTypeBadge,
|
ProxmoxBackupWorkloadTypeBadge,
|
||||||
SortableHead,
|
SortableHead,
|
||||||
|
formatCompactBackupAge,
|
||||||
} from '../proxmoxBackupsTableShared';
|
} from '../proxmoxBackupsTableShared';
|
||||||
import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel';
|
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', () => {
|
it('keeps backup age text on the shared relative-time primitive', () => {
|
||||||
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
|
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
|
||||||
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
|
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => {
|
|||||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
|
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 columns = getProxmoxHostVisibleColumnsForLayout('phone');
|
||||||
const ids = columns.map((column) => column.id);
|
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(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]');
|
||||||
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' });
|
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' });
|
||||||
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' });
|
expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||||
expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' });
|
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' });
|
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prioritizes live utilization columns in the mobile host table', () => {
|
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. */
|
/** Operator-facing source/repository label used by the backup-location filter. */
|
||||||
locationLabel?: string;
|
locationLabel?: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
/** Full provider identifier behind a shortened `detail`, shown on hover. */
|
||||||
|
detailTitle?: string;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** A still-running backup is writing this artifact; it is not recoverable yet. */
|
/** A still-running backup is writing this artifact; it is not recoverable yet. */
|
||||||
@@ -70,7 +72,13 @@ export interface WorkloadCoverageRow {
|
|||||||
key: string;
|
key: string;
|
||||||
workload: WorkloadReference;
|
workload: WorkloadReference;
|
||||||
artifacts: RecoverableArtifact[];
|
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;
|
latestPBS?: RecoverableArtifact;
|
||||||
latestArchive?: RecoverableArtifact;
|
latestArchive?: RecoverableArtifact;
|
||||||
latestSnapshot?: RecoverableArtifact;
|
latestSnapshot?: RecoverableArtifact;
|
||||||
@@ -138,12 +146,25 @@ export function getRecoveryAgeBand(
|
|||||||
return 'stale';
|
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 {
|
function parseTimestampMs(value: string | undefined): number | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const ms = Date.parse(value);
|
const ms = Date.parse(value);
|
||||||
return Number.isFinite(ms) ? ms : undefined;
|
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 {
|
function pbsBackupFileDetailLabel(fileCount: number): string {
|
||||||
if (fileCount <= 0) return 'PBS files not listed';
|
if (fileCount <= 0) return 'PBS files not listed';
|
||||||
if (fileCount === 1) return '1 PBS file';
|
if (fileCount === 1) return '1 PBS file';
|
||||||
@@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
size: archive.size,
|
size: archive.size,
|
||||||
location: archive.storage || archive.node || '—',
|
location: archive.storage || archive.node || '—',
|
||||||
...artifactLocationScope('archive', archive.instance, 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,
|
protected: archive.protected,
|
||||||
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
|
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
|
||||||
running: archive.inProgress === true,
|
running: archive.inProgress === true,
|
||||||
@@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
// the "latest" pointers (which answer "what can I recover to?") skip them.
|
// the "latest" pointers (which answer "what can I recover to?") skip them.
|
||||||
// The artifacts themselves stay listed with an honest state.
|
// The artifacts themselves stay listed with an honest state.
|
||||||
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||||
|
row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs);
|
||||||
row.latestPBS = newest(
|
row.latestPBS = newest(
|
||||||
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
|
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
|
||||||
(artifact) => artifact.createdMs,
|
(artifact) => artifact.createdMs,
|
||||||
@@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||||
(artifact) => artifact.createdMs,
|
(artifact) => artifact.createdMs,
|
||||||
);
|
);
|
||||||
row.latestRecovery = newest(completed, (artifact) => artifact.createdMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverageRows = Array.from(rows.values()).map((row) => {
|
const coverageRows = Array.from(rows.values()).map((row) => {
|
||||||
@@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel(
|
|||||||
|
|
||||||
coverageRows.sort((left, right) => {
|
coverageRows.sort((left, right) => {
|
||||||
if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank;
|
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));
|
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
|
||||||
|
|
||||||
@@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch(
|
|||||||
artifact.sourceTitle,
|
artifact.sourceTitle,
|
||||||
artifact.location,
|
artifact.location,
|
||||||
artifact.detail,
|
artifact.detail,
|
||||||
|
artifact.detailTitle,
|
||||||
artifact.verified === true
|
artifact.verified === true
|
||||||
? 'verified'
|
? 'verified'
|
||||||
: artifact.verified === false
|
: artifact.verified === false
|
||||||
|
|||||||
@@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record<
|
|||||||
> = {
|
> = {
|
||||||
// Reachability and datastore exhaustion are the two risks that can stop all
|
// Reachability and datastore exhaustion are the two risks that can stop all
|
||||||
// future backups. Host telemetry and space-efficiency context return only as
|
// future backups. Host telemetry and space-efficiency context return only as
|
||||||
// the table gains enough room to keep those headline answers readable.
|
// the table gains enough room to keep those headline answers readable. The
|
||||||
compact: ['server', 'status', 'datastore', 'used', 'backups'],
|
// 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'],
|
basic: ['server', 'status', 'datastore', 'used', 'backups'],
|
||||||
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
|
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
|
||||||
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
|
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
|
||||||
@@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record<
|
|||||||
ProxmoxBackupsTableLayoutMode,
|
ProxmoxBackupsTableLayoutMode,
|
||||||
Partial<Record<BackupServerColumnId, number>>
|
Partial<Record<BackupServerColumnId, number>>
|
||||||
> = {
|
> = {
|
||||||
compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 },
|
compact: { server: 40, status: 23, datastore: 22, used: 15 },
|
||||||
basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 },
|
// 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: {
|
operational: {
|
||||||
server: 20,
|
server: 20,
|
||||||
status: 12,
|
status: 12,
|
||||||
@@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
|||||||
{ id: 'targetId', label: 'Target ID', kind: 'text' },
|
{ id: 'targetId', label: 'Target ID', kind: 'text' },
|
||||||
{ id: 'node', label: 'Node', kind: 'text' },
|
{ id: 'node', label: 'Node', kind: 'text' },
|
||||||
{ id: 'posture', label: 'Posture', 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: 'pbs', label: 'PBS snapshot', kind: 'text' },
|
||||||
{ id: 'archive', label: 'PVE file', kind: 'text' },
|
{ id: 'archive', label: 'PVE file', kind: 'text' },
|
||||||
{ id: 'snapshot', label: 'Guest snapshot', 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[]> = {
|
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
|
||||||
// On narrow surfaces answer: which workload, what posture, how recent is the
|
// 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
|
// evidence is progressive detail in the expansion row; target identity folds
|
||||||
// beneath the name so the scan stays legible at 320px.
|
// beneath the name so the scan stays legible at 320px.
|
||||||
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
|
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
|
||||||
@@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record<
|
|||||||
ProxmoxBackupsTableLayoutMode,
|
ProxmoxBackupsTableLayoutMode,
|
||||||
Partial<Record<CoverageColumnId, number>>
|
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
|
// 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
|
// slack that the short type badge, target id, and age cells cannot use, so
|
||||||
// names stay whole instead of truncating beside empty space.
|
// 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 },
|
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
|
||||||
expanded: {
|
expanded: {
|
||||||
workload: 19,
|
workload: 19,
|
||||||
@@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly Recove
|
|||||||
// A recovery-point feed starts with identity, source, location, age, and
|
// A recovery-point feed starts with identity, source, location, age, and
|
||||||
// verification. Size and verbose details progressively return with usable
|
// verification. Size and verbose details progressively return with usable
|
||||||
// room; the full values remain available through their title affordance.
|
// 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'],
|
basic: ['workload', 'source', 'location', 'created', 'state'],
|
||||||
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
|
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
|
||||||
expanded: ['workload', 'type', 'targetId', '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
|
// Reserve enough phone-width space for complete state badges (including
|
||||||
// Failed and Running) rather than clipping the recovery answer at the
|
// Failed and Running) rather than clipping the recovery answer at the
|
||||||
// scroll edge. Identity remains on the canonical 40% anchor.
|
// 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 },
|
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
|
||||||
operational: {
|
operational: {
|
||||||
workload: 24,
|
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 { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||||
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
|
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||||
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
|
|||||||
import { TableHead } from '@/components/shared/Table';
|
import { TableHead } from '@/components/shared/Table';
|
||||||
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
|
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
|
||||||
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
|
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
|
||||||
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
import {
|
||||||
|
PlatformTableRelativeTimeValue,
|
||||||
|
formatPlatformTableRelativeTimeValue,
|
||||||
|
} from '@/features/platformPage/sharedPlatformPage';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getRecoveryAgeBand,
|
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 band = () => getRecoveryAgeBand(props.artifact.createdMs);
|
||||||
const title = () => {
|
const title = () => {
|
||||||
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
|
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
|
||||||
@@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
|||||||
cpu: 'narrow',
|
cpu: 'narrow',
|
||||||
memory: 'narrow',
|
memory: 'narrow',
|
||||||
disk: 'narrow',
|
disk: 'narrow',
|
||||||
temp: 'phone',
|
temp: 'mobile',
|
||||||
uptime: 'narrow',
|
uptime: 'narrow',
|
||||||
cluster: 'tablet',
|
cluster: 'tablet',
|
||||||
vms: 'compact',
|
vms: 'compact',
|
||||||
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
|||||||
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
|
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
|
||||||
// pill) take only what they need.
|
// pill) take only what they need.
|
||||||
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||||
node: 18,
|
node: 20,
|
||||||
version: 7,
|
version: 6,
|
||||||
uptime: 7,
|
uptime: 7,
|
||||||
cpu: 13,
|
cpu: 13,
|
||||||
memory: 13,
|
memory: 13,
|
||||||
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
|||||||
temp: 6,
|
temp: 6,
|
||||||
vms: 5,
|
vms: 5,
|
||||||
cts: 5,
|
cts: 5,
|
||||||
cluster: 13,
|
cluster: 12,
|
||||||
};
|
};
|
||||||
|
|
||||||
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||||
@@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
|||||||
disk: 15,
|
disk: 15,
|
||||||
uptime: 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: {
|
phone: {
|
||||||
node: 32,
|
node: 32,
|
||||||
cpu: 13,
|
cpu: 15,
|
||||||
memory: 13,
|
memory: 15,
|
||||||
disk: 13,
|
disk: 15,
|
||||||
temp: 15,
|
uptime: 15,
|
||||||
uptime: 14,
|
|
||||||
},
|
},
|
||||||
mobile: {
|
mobile: {
|
||||||
node: 32,
|
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 = (
|
export const getProxmoxHostColumnWidthStyle = (
|
||||||
columnId: ProxmoxHostTableColumnId,
|
columnId: ProxmoxHostTableColumnId,
|
||||||
layoutMode: WorkloadTableLayoutMode,
|
layoutMode: WorkloadTableLayoutMode,
|
||||||
@@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = (
|
|||||||
visibleColumnIds,
|
visibleColumnIds,
|
||||||
layoutMode === 'narrow'
|
layoutMode === 'narrow'
|
||||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
: layoutMode === 'phone'
|
||||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||||
: undefined,
|
: layoutMode === 'mobile'
|
||||||
|
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DISK_DETAIL_LIVE_CHARTS,
|
DISK_DETAIL_LIVE_CHARTS,
|
||||||
getDiskAttributeValueTextClass,
|
getDiskAttributeValueTextClass,
|
||||||
getDiskDetailAttributeCards,
|
getDiskDetailAttributeCards,
|
||||||
|
getDiskDetailHealthPresentation,
|
||||||
getDiskDetailHistoryCharts,
|
getDiskDetailHistoryCharts,
|
||||||
getDiskDetailHistoryFallbackMessage,
|
getDiskDetailHistoryFallbackMessage,
|
||||||
getDiskDetailLiveBadgeLabel,
|
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', () => {
|
it('builds canonical SATA and NVMe attribute cards', () => {
|
||||||
expect(
|
expect(
|
||||||
getDiskDetailAttributeCards({
|
getDiskDetailAttributeCards({
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
PHYSICAL_DISK_TABLE_CLASS,
|
PHYSICAL_DISK_TABLE_CLASS,
|
||||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||||
getPhysicalDiskEmptyStatePresentation,
|
getPhysicalDiskEmptyStatePresentation,
|
||||||
getPhysicalDiskColumnWidthPercent,
|
getPhysicalDiskCellPaddingClass,
|
||||||
|
getPhysicalDiskColumnWidthStyle,
|
||||||
getPhysicalDiskCollectionMessages,
|
getPhysicalDiskCollectionMessages,
|
||||||
getPhysicalDiskFieldStatusMessage,
|
getPhysicalDiskFieldStatusMessage,
|
||||||
|
getPhysicalDiskHealthCompactLabel,
|
||||||
getPhysicalDiskHealthStatus,
|
getPhysicalDiskHealthStatus,
|
||||||
getPhysicalDiskHealthSummary,
|
getPhysicalDiskHealthSummary,
|
||||||
getPhysicalDiskHostLabel,
|
getPhysicalDiskHostLabel,
|
||||||
@@ -158,14 +160,55 @@ describe('diskPresentation', () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33);
|
expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' });
|
||||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14);
|
expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' });
|
||||||
expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9);
|
expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' });
|
||||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0);
|
expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' });
|
||||||
|
expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' });
|
||||||
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
|
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
|
||||||
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
|
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
|
||||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40);
|
expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' });
|
||||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15);
|
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', () => {
|
it('detects SMART warnings from counters', () => {
|
||||||
|
|||||||
+2
-2
@@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
|||||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
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 = {
|
const record = {
|
||||||
...baseRecord(),
|
...baseRecord(),
|
||||||
details: { zfsPool: { state: 'FAULTED', devices: [] } },
|
details: { zfsPool: { state: 'FAULTED', devices: [] } },
|
||||||
} as unknown as StorageRecord;
|
} 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)', () => {
|
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
|
||||||
|
|||||||
+2
-2
@@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
|||||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
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 = {
|
const record = {
|
||||||
...baseRecord(),
|
...baseRecord(),
|
||||||
details: {
|
details: {
|
||||||
@@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as StorageRecord;
|
} 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', () => {
|
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');
|
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', () => {
|
it('derives zfs issue fallback from pool state and errors', () => {
|
||||||
const record = {
|
const record = {
|
||||||
...baseRecord(),
|
...baseRecord(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
STORAGE_BAR_ZFS_SECTION_CLASS,
|
STORAGE_BAR_ZFS_SECTION_CLASS,
|
||||||
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
|
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
|
||||||
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
|
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
|
||||||
|
getStorageBarCompactLabel,
|
||||||
getStorageBarLabel,
|
getStorageBarLabel,
|
||||||
getStorageBarTooltipRowClass,
|
getStorageBarTooltipRowClass,
|
||||||
getStorageBarTooltipRows,
|
getStorageBarTooltipRows,
|
||||||
@@ -39,6 +40,7 @@ describe('storageBarPresentation', () => {
|
|||||||
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
|
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
|
||||||
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
|
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
|
||||||
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
|
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
|
||||||
|
expect(getStorageBarCompactLabel(40, 100)).toBe('40%');
|
||||||
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
|
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
|
||||||
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
|
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
|
||||||
expect(getStorageBarTooltipRowClass(true)).toBe(
|
expect(getStorageBarTooltipRowClass(true)).toBe(
|
||||||
|
|||||||
+32
-9
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
getStoragePoolColumnWidthPercent,
|
getStoragePoolCellPaddingClass,
|
||||||
|
getStoragePoolColumnWidthStyle,
|
||||||
getStoragePoolTableColumns,
|
getStoragePoolTableColumns,
|
||||||
getStoragePoolTableLayoutModeForContainer,
|
getStoragePoolTableLayoutModeForContainer,
|
||||||
getStorageTableHeading,
|
getStorageTableHeading,
|
||||||
@@ -46,22 +47,44 @@ describe('storagePagePresentation', () => {
|
|||||||
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
|
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
|
||||||
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
|
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
|
||||||
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
|
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
|
||||||
expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact');
|
// The compact/operational boundary is the shared phone container query
|
||||||
expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational');
|
// (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_039)).toBe('operational');
|
||||||
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
|
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
|
||||||
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
|
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
|
||||||
expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false);
|
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', 'host')).toBe(true);
|
||||||
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
|
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
|
||||||
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
|
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
|
||||||
expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35);
|
expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' });
|
||||||
expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25);
|
expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' });
|
||||||
expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0);
|
expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' });
|
||||||
|
expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' });
|
||||||
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
|
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
|
||||||
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
|
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
|
||||||
expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40);
|
expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' });
|
||||||
expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20);
|
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 type { HistoryTimeRange } from '@/api/charts';
|
||||||
import { formatPowerOnHours } from '@/utils/format';
|
import { formatPowerOnHours } from '@/utils/format';
|
||||||
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
|
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||||
@@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time';
|
|||||||
export const getDiskDetailHistoryFallbackMessage = (): string =>
|
export const getDiskDetailHistoryFallbackMessage = (): string =>
|
||||||
'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.';
|
'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(
|
export function getDiskDetailAttributeCards(
|
||||||
disk: PhysicalDiskPresentationData,
|
disk: PhysicalDiskPresentationData,
|
||||||
diskTempThresholds?: MetricDisplayThresholds | null,
|
diskTempThresholds?: MetricDisplayThresholds | null,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { JSX } from 'solid-js';
|
||||||
import type {
|
import type {
|
||||||
PhysicalDiskCollectionStatus,
|
PhysicalDiskCollectionStatus,
|
||||||
PhysicalDiskFieldStatus,
|
PhysicalDiskFieldStatus,
|
||||||
Resource,
|
Resource,
|
||||||
} from '@/types/resource';
|
} from '@/types/resource';
|
||||||
|
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||||
import {
|
import {
|
||||||
getSourcePlatformLabel,
|
getSourcePlatformLabel,
|
||||||
getSourcePlatformPresentation,
|
getSourcePlatformPresentation,
|
||||||
@@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record<
|
|||||||
full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'],
|
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,
|
PhysicalDiskTableLayoutMode,
|
||||||
Partial<Record<PhysicalDiskTableColumnId, number>>
|
Partial<Record<PhysicalDiskTableColumnId, number>>
|
||||||
> = {
|
> = {
|
||||||
narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 },
|
narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 },
|
||||||
compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 },
|
compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 },
|
||||||
basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 },
|
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 },
|
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 },
|
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,
|
columnId: PhysicalDiskTableColumnId,
|
||||||
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
|
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
|
||||||
|
|
||||||
export const getPhysicalDiskColumnWidthPercent = (
|
export const getPhysicalDiskColumnWidthStyle = (
|
||||||
layout: PhysicalDiskTableLayoutMode,
|
layout: PhysicalDiskTableLayoutMode,
|
||||||
columnId: PhysicalDiskTableColumnId,
|
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_DISK_CLASS = '';
|
||||||
export const PHYSICAL_DISK_COL_DEVICE_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_LIFE_CLASS = 'text-[11px] font-medium';
|
||||||
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
|
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
|
||||||
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
|
'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';
|
// The health word is the row's verdict and always renders whole. Its reason
|
||||||
export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
|
// 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_TEMPERATURE_CLASS = 'text-[11px] font-medium';
|
||||||
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
|
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';
|
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 {
|
export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string {
|
||||||
const label = (record.protectionLabel || '').trim();
|
const label = (record.protectionLabel || '').trim();
|
||||||
if (record.rebuildInProgress || record.protectionReduced) {
|
if (record.rebuildInProgress) {
|
||||||
|
return getStoragePoolRebuildLabel(record) || label || '—';
|
||||||
|
}
|
||||||
|
if (record.protectionReduced) {
|
||||||
return label || '—';
|
return label || '—';
|
||||||
}
|
}
|
||||||
if (label && label.toLowerCase() !== 'healthy') {
|
if (label && label.toLowerCase() !== 'healthy') {
|
||||||
@@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string {
|
|||||||
}
|
}
|
||||||
const pool = getStorageRecordZfsPool(record);
|
const pool = getStorageRecordZfsPool(record);
|
||||||
if (pool?.state) {
|
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);
|
const status = getStorageRecordStatus(record);
|
||||||
return status ? titleize(status) : '—';
|
return status ? titleize(status) : '—';
|
||||||
@@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str
|
|||||||
return issueSummary;
|
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 {
|
export function getCompactStoragePoolImpactLabel(record: StorageRecord): string {
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number =
|
|||||||
return (used / total) * 100;
|
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 =>
|
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';
|
export const getStorageBarTooltipTitle = (): string => 'Storage Details';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import type { JSX } from 'solid-js';
|
||||||
import type { StorageSortKey } from './storageModelCore';
|
import type { StorageSortKey } from './storageModelCore';
|
||||||
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
|
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
|
||||||
|
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||||
|
|
||||||
export type StorageViewOption = {
|
export type StorageViewOption = {
|
||||||
value: 'pools' | 'disks';
|
value: 'pools' | 'disks';
|
||||||
@@ -21,11 +23,14 @@ export type StoragePoolTableColumn = {
|
|||||||
|
|
||||||
export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full';
|
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 = (
|
export const getStoragePoolTableLayoutModeForContainer = (
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
): StoragePoolTableLayoutMode => {
|
): StoragePoolTableLayoutMode => {
|
||||||
if (containerWidth >= 1_040) return 'full';
|
if (containerWidth >= 1_040) return 'full';
|
||||||
if (containerWidth >= 560) return 'operational';
|
if (containerWidth >= 544) return 'operational';
|
||||||
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
|
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
|
||||||
return 'compact';
|
return 'compact';
|
||||||
};
|
};
|
||||||
@@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record<
|
|||||||
full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'],
|
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,
|
StoragePoolTableLayoutMode,
|
||||||
Partial<Record<StoragePoolTableColumnId, number>>
|
Partial<Record<StoragePoolTableColumnId, number>>
|
||||||
> = {
|
> = {
|
||||||
narrow: { name: 40, state: 20, host: 20, usage: 20 },
|
narrow: { name: 35, state: 21, host: 31, usage: 13 },
|
||||||
compact: { name: 35, state: 20, host: 20, usage: 25 },
|
compact: { name: 37, state: 18.5, host: 31.5, usage: 13 },
|
||||||
operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 },
|
// 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 },
|
full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = (
|
|||||||
columnId: StoragePoolTableColumnId,
|
columnId: StoragePoolTableColumnId,
|
||||||
): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId);
|
): 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,
|
layout: StoragePoolTableLayoutMode,
|
||||||
columnId: StoragePoolTableColumnId,
|
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 =
|
const STORAGE_POOL_TABLE_HEADER_CLASS =
|
||||||
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider';
|
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider';
|
||||||
|
|||||||
Reference in New Issue
Block a user