mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +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
|
||||
|
||||
### Update History consent does not infer the backup's restore scope
|
||||
|
||||
The confirmation names the observed running server separately from the selected
|
||||
entry's pre-/post-update versions. No version threshold or free-form notes stand
|
||||
in for actual scope: the current History API does not describe the manifest or
|
||||
active-store layout. Explain that installation-only recovery leaves active stores
|
||||
in place but may restore independent installation files; legacy recovery can
|
||||
also replace install-local configuration/data. Do not promise a settings/alerts
|
||||
rewind. Full-state recovery still requires a stopped service and a consistent
|
||||
backup of every active store, via the existing shipped recovery instructions.
|
||||
|
||||
Opening/cancelling consent sends no rollback request, pending consent rejects
|
||||
duplicate actions and cancellation, and a rejected request retains the warning
|
||||
and refreshes history. Mounted UpdateHistory tests and the production-CSS offline
|
||||
browser fixture exercise those states; neither proves a binary/data rollback.
|
||||
|
||||
|
||||
### Demo transactions share the service host, not just a workflow queue
|
||||
|
||||
Stable demo update and recovery keep their existing publication/activation,
|
||||
|
||||
@@ -20,6 +20,36 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Composed operator tables and conservative rollback consent
|
||||
|
||||
Phone storage tables use the canonical weighted-width helper and matching
|
||||
header/cell gutters. Compact labels shorten disk verdicts and capacity values,
|
||||
not their underlying health or measured units; full reasons stay in the real
|
||||
disk header and tooltips. Structured ZFS scan activity supplies a compact badge
|
||||
only during reported rebuild activity; its complete provider summary remains
|
||||
available. Stable disk History catalog/organisation/access ownership is unchanged.
|
||||
|
||||
Controller expansions carry the supplied kind-specific targets, absolute times,
|
||||
duration and cluster/namespace when the phone row omits those columns. Proxmox
|
||||
coverage distinguishes independent backups from guest-local snapshots; By date
|
||||
retains archive identifiers in hover/search while showing the format as detail.
|
||||
Phone identity weights leave every configured metric column visible.
|
||||
|
||||
UpdateHistory consent names both the requested target and the observed running
|
||||
version, separately from the version on the selected history entry. The API does
|
||||
not expose backup restore scope: do not infer it from a version cutoff, notes or
|
||||
a retained path. Explain installation-only versus legacy recovery and stopped,
|
||||
consistent full-state recovery without promising later settings will be reverted
|
||||
or preserved. Opening/cancelling never starts a write, rejection retains consent,
|
||||
and a pending request cannot be submitted again or cancelled.
|
||||
|
||||
Verification: the mounted UpdateHistory regression tests, existing kind-specific
|
||||
controller/storage/backup/workload tests and production-CSS browser fixture
|
||||
`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed
|
||||
source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
|
||||
not establish native collectors, tenancy, installation recovery or release delivery.
|
||||
|
||||
|
||||
### Organisation-scoped single-metric History
|
||||
|
||||
Fetched HistoryChart observations belong to the active organisation as well as
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Phone workload identity weights
|
||||
|
||||
The existing phone guest projection allocates 38 percent to the name cell,
|
||||
which includes disclosure, status and backup controls, while normalising every
|
||||
remaining visible metric weight. Narrow/mobile/desktop modes and windowing
|
||||
ownership remain unchanged. Guest column regressions verify the sums and the
|
||||
production composed-table browser fixture checks identity and all configured
|
||||
metric tracks at narrow width; this is readability, not a throughput claim.
|
||||
|
||||
|
||||
### Bounded confidence/priority work in broadcast grouping — issue #2199
|
||||
|
||||
The general identity matcher retains every confidence, reason, ordering and
|
||||
|
||||
@@ -21,6 +21,28 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Backup identity, phone storage and History consent composition
|
||||
|
||||
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
|
||||
backup files, never guest-local snapshots. Guest snapshots remain listed as
|
||||
restore evidence but cannot make an old independent backup look current.
|
||||
Archive detail uses its format (or filename fallback), preserving the full
|
||||
provider volid for hover and search in both Coverage and By date.
|
||||
|
||||
Phone pool/disk rows preserve health and identity while using weighted widths,
|
||||
consistent gutters and compact capacity/health labels. Full disk risk reasons
|
||||
move into the existing expanded header; the ZFS activity badge uses supplied
|
||||
scan data only when rebuild is reported and keeps the full summary as its title.
|
||||
Collection-independent, organisation-owned disk History remains intact.
|
||||
|
||||
The UpdateHistory confirmation is a version-rollback consent, not a full-state
|
||||
recovery promise. Its current API exposes no verified restore-scope metadata;
|
||||
copy must retain installation-only and legacy differences and direct full-state
|
||||
recovery to the stopped-service procedure. Regressions cover these distinctions,
|
||||
archive/snapshot attribution and storage values; the composed browser fixture
|
||||
checks real table/drawer rendering, not native or installed recovery.
|
||||
|
||||
|
||||
### Disk History is independent of current collection
|
||||
|
||||
The existing physical-disk History catalog is selected by disk family, not by
|
||||
|
||||
@@ -23,6 +23,23 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
### Narrow controller and backup summaries retain their source facts
|
||||
|
||||
Kubernetes controller row expansions expose supplied kind-specific targets,
|
||||
counts, absolute timestamps, valid elapsed durations and namespace/cluster when
|
||||
responsive rows hide those columns. Missing facts are omitted and the ordinary
|
||||
pluralisation model handles singular targets. Job/CronJob timestamp regressions
|
||||
and the production composed-table browser fixture verify this existing summary.
|
||||
|
||||
Proxmox Coverage uses latestBackup for completed independent backup evidence,
|
||||
with guest-local snapshots retained separately. Responsive backup-server, host
|
||||
and guest rows allocate space through existing canonical weighted-width helpers;
|
||||
archive format is presentation only, with the provider identifier retained in
|
||||
hover and search. The recovery-model, table and guest regressions plus desktop/
|
||||
phone browser rendering verify attribution, visible facts and source composition.
|
||||
These frontend improvements change no collector, API authority or native status.
|
||||
|
||||
|
||||
### Resource evidence fallback access boundaries
|
||||
|
||||
When the existing resource-facet reader receives a final HTTP 401/403, its
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/operator-tables-proof';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
process.chdir(root);
|
||||
const playwright = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json')));
|
||||
const hashes = Object.fromEntries(
|
||||
paths.map((p) => [
|
||||
p,
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join('/workspace', p)))
|
||||
.digest('hex'),
|
||||
]),
|
||||
);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5271, strictPort: true },
|
||||
});
|
||||
const results = [],
|
||||
screens = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width] of [
|
||||
['desktop', chromium, 1365],
|
||||
['phone', webkit, 390],
|
||||
['intermediate', chromium, 768],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
isMobile: width === 390,
|
||||
hasTouch: width === 390,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
|
||||
const errors = [],
|
||||
offOrigin = [],
|
||||
checks = [],
|
||||
requests = [];
|
||||
let rollbackMode = 'reject',
|
||||
finishRollback;
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.setDefaultTimeout(20000);
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5271') {
|
||||
offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/updates/history')
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
event_id: 'fixture-update-1',
|
||||
timestamp: '2026-10-02T12:00:00Z',
|
||||
action: 'update',
|
||||
channel: 'stable',
|
||||
version_from: '6.4.5',
|
||||
version_to: '6.4.6',
|
||||
deployment_type: 'systemd',
|
||||
initiated_by: 'user',
|
||||
initiated_via: 'ui',
|
||||
status: 'success',
|
||||
duration_ms: 30000,
|
||||
backup_path: '/private/fixture-backup',
|
||||
notes: 'untrusted restore-everything note',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (url.pathname === '/api/version')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
version: '6.6.0-dev',
|
||||
build: 'synthetic',
|
||||
runtime: 'community',
|
||||
isDocker: false,
|
||||
isSourceBuild: true,
|
||||
isDevelopment: false,
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/updates/rollback') {
|
||||
requests.push({ method: route.request().method(), body: route.request().postDataJSON() });
|
||||
if (rollbackMode === 'hold')
|
||||
await new Promise((resolve) => {
|
||||
finishRollback = resolve;
|
||||
});
|
||||
return rollbackMode === 'accept'
|
||||
? route.fulfill({
|
||||
json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' },
|
||||
})
|
||||
: route.fulfill({
|
||||
status: 409,
|
||||
json: { error: 'Synthetic incompatible backup scope' },
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/license/entitlements')
|
||||
return route.fulfill({ json: { entitlements: [] } });
|
||||
if (url.pathname.includes('/facets'))
|
||||
return route.fulfill({
|
||||
json: { facets: [], capabilities: [], relationships: [], recentChanges: [] },
|
||||
});
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: true },
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/metrics-store/history')
|
||||
return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } });
|
||||
return route.fulfill({
|
||||
json: {
|
||||
enabled: false,
|
||||
data: [],
|
||||
alerts: {},
|
||||
facets: [],
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
const check = async (label, run) => {
|
||||
try {
|
||||
await run();
|
||||
checks.push({ label, passed: true });
|
||||
} catch (e) {
|
||||
checks.push({ label, passed: false, error: e.message });
|
||||
}
|
||||
};
|
||||
const select = async (name) => {
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Fixture views' })
|
||||
.getByRole('button', { name, exact: true })
|
||||
.click();
|
||||
};
|
||||
const capture = async (state) => {
|
||||
// Await the production entrance animation instead of capturing translucent frames.
|
||||
if (await page.getByRole('dialog').count()) {
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector('[role=dialog]');
|
||||
return panel && Number(getComputedStyle(panel).opacity) >= 0.999;
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof.
|
||||
const p = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, p), fullPage: true });
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
screens.push({ file: p, state, browser: name, width, dimensions });
|
||||
};
|
||||
await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120000,
|
||||
});
|
||||
if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await check('Pool state and measured capacity survive the phone projection', async () => {
|
||||
const pool = page.getByRole('region', { name: 'Pool table' });
|
||||
await pool.getByText('archive-tank', { exact: true }).waitFor();
|
||||
await pool.getByText('Degraded', { exact: true }).waitFor();
|
||||
const state = pool.locator('tbody td[data-storage-column="state"]').first();
|
||||
const bounds = await state.evaluate((e) => ({
|
||||
width: e.clientWidth,
|
||||
scroll: e.scrollWidth,
|
||||
text: e.textContent,
|
||||
}));
|
||||
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
|
||||
if (width >= 544) {
|
||||
await pool.getByText('Resilvering 45%', { exact: true }).waitFor();
|
||||
await pool.getByText('Scrubbing 45%', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
await pool
|
||||
.locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]')
|
||||
.count(),
|
||||
);
|
||||
} else {
|
||||
assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2);
|
||||
}
|
||||
await capture('pools');
|
||||
});
|
||||
if (width !== 768) {
|
||||
await select('Disks');
|
||||
await check('Disk row and expanded header retain the full SMART reason', async () => {
|
||||
await page.getByText('Archive HDD', { exact: true }).waitFor();
|
||||
await page
|
||||
.locator('[data-row-id="disk-sda"]')
|
||||
.getByText('Archive HDD', { exact: true })
|
||||
.click();
|
||||
const health = page.getByTestId('disk-detail-health');
|
||||
await health.getByText('Replace Now', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
(await health.innerText()).includes('SMART failed. The full reason remains readable'),
|
||||
);
|
||||
const bounds = await health.evaluate((e) => ({
|
||||
width: e.clientWidth,
|
||||
scroll: e.scrollWidth,
|
||||
}));
|
||||
assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds));
|
||||
await capture('disk-reason');
|
||||
});
|
||||
await select('Controllers');
|
||||
await check(
|
||||
'Job target and absolute started/completed times are reachable from a narrow row',
|
||||
async () => {
|
||||
await page.getByText('nightly-import', { exact: true }).click();
|
||||
const details = page.getByTestId('resource-kubernetes-controller-section');
|
||||
await details.getByText('Started', { exact: true }).waitFor();
|
||||
await details.getByText('Completed', { exact: true }).waitFor();
|
||||
await details.getByText('5m', { exact: true }).waitFor();
|
||||
await details.getByText('1 completion', { exact: true }).waitFor();
|
||||
assert.ok((await details.innerText()).includes('2026'));
|
||||
await capture('job-times');
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'CronJob retains last-run, last-success, schedule and suspension in its expansion',
|
||||
async () => {
|
||||
await page.getByText('billing-rollup', { exact: true }).click();
|
||||
const details = page.getByTestId('resource-kubernetes-controller-section');
|
||||
await details.getByText('Last run', { exact: true }).waitFor();
|
||||
await details.getByText('Last success', { exact: true }).waitFor();
|
||||
await details.getByText('*/5 * * * *', { exact: true }).waitFor();
|
||||
await details.getByText('Suspended', { exact: true }).waitFor();
|
||||
await capture('cron-times');
|
||||
},
|
||||
);
|
||||
}
|
||||
await select('Backups');
|
||||
await check(
|
||||
'Coverage last backup remains older than a fresh local guest snapshot',
|
||||
async () => {
|
||||
const coverage = page.locator('[data-proxmox-backups-table="coverage"]');
|
||||
await coverage.waitFor();
|
||||
const columns = await coverage
|
||||
.locator('colgroup col')
|
||||
.evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn));
|
||||
const row = coverage.locator('tr[data-proxmox-backup-row=coverage]');
|
||||
const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText();
|
||||
assert.ok(backupAge.includes('13d'), backupAge);
|
||||
assert.ok(!backupAge.includes('30m'), backupAge);
|
||||
if (columns.includes('snapshot')) {
|
||||
assert.ok(
|
||||
(await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes(
|
||||
'30m',
|
||||
),
|
||||
);
|
||||
}
|
||||
await capture('backup-attribution');
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'By date keeps archive format visible and full identifier available at wide widths',
|
||||
async () => {
|
||||
const byDate = page.locator('[data-proxmox-backups-table="recoverable"]');
|
||||
await byDate.waitFor();
|
||||
if (width >= 1120) {
|
||||
await byDate.getByText('tar.zst', { exact: true }).waitFor();
|
||||
assert.ok(
|
||||
await byDate
|
||||
.locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]')
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2);
|
||||
},
|
||||
);
|
||||
if (width !== 768) {
|
||||
await select('Consent');
|
||||
await check(
|
||||
'Consent separates running/backup versions and does not promise active-data restoration',
|
||||
async () => {
|
||||
const action = page.getByRole('button', { name: 'Roll back', exact: true });
|
||||
await action.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm rollback' });
|
||||
await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor();
|
||||
await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor();
|
||||
const text = await dialog.innerText();
|
||||
assert.ok(text.includes('Restore scope is not reported for this backup.'));
|
||||
assert.ok(text.includes('Older recovery can also replace install-local data'));
|
||||
assert.ok(text.includes('Do not assume this will undo settings or alerts.'));
|
||||
assert.ok(!text.includes('/private/fixture-backup'));
|
||||
assert.ok(!text.includes('untrusted restore-everything note'));
|
||||
assert.equal(
|
||||
await dialog
|
||||
.getByRole('link', { name: 'Read the recovery instructions' })
|
||||
.getAttribute('href'),
|
||||
'/docs/AUTO_UPDATE#manual-rollback',
|
||||
);
|
||||
assert.equal(requests.length, 0);
|
||||
await capture('consent');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(await page.getByRole('dialog').count(), 0);
|
||||
assert.equal(requests.length, 0);
|
||||
assert.equal(await action.evaluate((e) => e === document.activeElement), true);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'Rejection retains consent and pending submission cannot be duplicated or cancelled',
|
||||
async () => {
|
||||
await page.getByRole('button', { name: 'Roll back', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
|
||||
await page
|
||||
.getByText('Synthetic incompatible backup scope', { exact: true })
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.ok(
|
||||
(await page.getByRole('dialog').innerText()).includes(
|
||||
'Restore scope is not reported',
|
||||
),
|
||||
);
|
||||
await capture('consent-rejected');
|
||||
rollbackMode = 'hold';
|
||||
await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('button:disabled')?.textContent,
|
||||
);
|
||||
assert.ok(
|
||||
await page
|
||||
.getByRole('button', { name: 'Starting rollback...', exact: true })
|
||||
.isDisabled(),
|
||||
);
|
||||
assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled());
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.getByRole('dialog').count(), 1);
|
||||
while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(requests.length, 2);
|
||||
rollbackMode = 'accept';
|
||||
finishRollback();
|
||||
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
||||
assert.deepEqual(requests, [
|
||||
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
|
||||
{ method: 'POST', body: { eventId: 'fixture-update-1' } },
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
await check('No uncaught application errors or external requests', async () => {
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(offOrigin, []);
|
||||
});
|
||||
results.push({
|
||||
name,
|
||||
width,
|
||||
browser: browser.version(),
|
||||
checks,
|
||||
errors,
|
||||
offOrigin,
|
||||
rollbackRequests: requests,
|
||||
});
|
||||
await page.close();
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
const result = {
|
||||
result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed',
|
||||
playwright,
|
||||
base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4',
|
||||
source_content_sha256: hashes,
|
||||
results,
|
||||
screens,
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
result: result.result,
|
||||
checks: results.flatMap((r) => r.checks).length,
|
||||
failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)),
|
||||
screens: screens.length,
|
||||
}),
|
||||
);
|
||||
if (result.result !== 'passed') process.exitCode = 1;
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Operator tables and rollback consent verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./operator-tables-consent.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,399 @@
|
||||
// Production components and CSS with synthetic observations. No native, released or
|
||||
// installed recovery claim; the current history API does not describe restore scope.
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable';
|
||||
import { DiskList } from '../src/components/Storage/DiskList';
|
||||
import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable';
|
||||
import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable';
|
||||
import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable';
|
||||
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
|
||||
import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
|
||||
import { GuestRow } from '../src/components/Workloads/GuestRow';
|
||||
import { Table, TableBody } from '../src/components/shared/Table';
|
||||
import { ToastContainer } from '../src/components/Toast/Toast';
|
||||
import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection';
|
||||
import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel';
|
||||
import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore';
|
||||
import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState';
|
||||
import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime';
|
||||
import { updateStore } from '../src/stores/updates';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import type { StorageRecord } from '../src/features/storageBackups/models';
|
||||
import '../src/index.css';
|
||||
|
||||
const now = Date.parse('2026-10-03T12:00:00Z');
|
||||
const resource = (id: string, type: Resource['type'], extra: Partial<Resource> = {}): Resource => ({
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
displayName: id,
|
||||
platformId: 'fixture-estate',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: now,
|
||||
...extra,
|
||||
});
|
||||
const pools: StorageRecord[] = [
|
||||
['archive-tank', 'DEGRADED', 'resilver'],
|
||||
['backup-pool', 'ONLINE', 'scrub'],
|
||||
].map(([name, state, scan], index) => ({
|
||||
id: name,
|
||||
name,
|
||||
category: 'pool',
|
||||
health: index ? 'healthy' : 'warning',
|
||||
location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' },
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 },
|
||||
capabilities: ['capacity', 'health'],
|
||||
observedAt: now,
|
||||
protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`,
|
||||
protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`,
|
||||
rebuildInProgress: true,
|
||||
details: {
|
||||
zfsPool: {
|
||||
state,
|
||||
scan: `${scan} in progress`,
|
||||
scanDetails: {
|
||||
function: scan.toUpperCase(),
|
||||
state: 'SCANNING',
|
||||
percentage: 45.2,
|
||||
},
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const disks = [
|
||||
resource('disk-sda', 'physical_disk', {
|
||||
identity: { hostname: 'archive-nas' },
|
||||
canonicalIdentity: { hostname: 'archive-nas' },
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'FIXTURE-1',
|
||||
diskType: 'hdd',
|
||||
sizeBytes: 2e12,
|
||||
health: 'FAILED',
|
||||
temperature: 42,
|
||||
storageRole: 'data',
|
||||
risk: {
|
||||
level: 'critical',
|
||||
reasons: [
|
||||
{
|
||||
code: 'smart-failed',
|
||||
severity: 'critical',
|
||||
summary: 'SMART failed. The full reason remains readable in the expanded disk header.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
resource('disk-nvme', 'physical_disk', {
|
||||
identity: { hostname: 'backup-nas' },
|
||||
canonicalIdentity: { hostname: 'backup-nas' },
|
||||
physicalDisk: {
|
||||
devPath: '/dev/nvme0n1',
|
||||
model: 'Backup SSD',
|
||||
serial: 'FIXTURE-2',
|
||||
diskType: 'nvme',
|
||||
sizeBytes: 1e12,
|
||||
health: 'PASSED',
|
||||
temperature: 44,
|
||||
wearout: 2,
|
||||
storageRole: 'cache',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const controllers = [
|
||||
resource('nightly-import', 'k8s-job', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'Job',
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
startTime: '2026-10-03T11:00:00Z',
|
||||
completionTime: '2026-10-03T11:05:00Z',
|
||||
desiredReplicas: 1,
|
||||
succeeded: 1,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
},
|
||||
}),
|
||||
resource('billing-rollup', 'k8s-cronjob', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'CronJob',
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
schedule: '*/5 * * * *',
|
||||
lastScheduleTime: '2026-10-03T11:00:00Z',
|
||||
lastSuccessfulTime: '2026-10-03T11:05:00Z',
|
||||
suspend: true,
|
||||
active: 0,
|
||||
},
|
||||
}),
|
||||
resource('node-exporter', 'k8s-daemonset', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'DaemonSet',
|
||||
clusterName: 'prod',
|
||||
namespace: 'observability',
|
||||
desiredNumberScheduled: 3,
|
||||
currentNumberScheduled: 3,
|
||||
numberReady: 2,
|
||||
numberUnavailable: 1,
|
||||
},
|
||||
}),
|
||||
resource('database', 'k8s-statefulset', {
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
resourceKind: 'StatefulSet',
|
||||
clusterName: 'prod',
|
||||
namespace: 'apps',
|
||||
desiredReplicas: 1,
|
||||
readyReplicas: 1,
|
||||
serviceName: 'database-headless',
|
||||
},
|
||||
}),
|
||||
];
|
||||
const workload = resource('ct-310', 'system-container', {
|
||||
name: 'artifact-cache-310',
|
||||
displayName: 'artifact-cache-310',
|
||||
status: 'running',
|
||||
proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' },
|
||||
});
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload],
|
||||
pbsBackups: [],
|
||||
tasks: [],
|
||||
nowMs: now,
|
||||
archives: [
|
||||
{
|
||||
id: 'file-310',
|
||||
storage: 'local',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
type: 'ct',
|
||||
vmid: 310,
|
||||
time: '2026-09-20T02:00:00Z',
|
||||
ctime: Date.parse('2026-09-20T02:00:00Z') / 1000,
|
||||
size: 1048576,
|
||||
format: 'tar.zst',
|
||||
protected: false,
|
||||
volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst',
|
||||
isPBS: false,
|
||||
verified: false,
|
||||
},
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
id: 'snap-310',
|
||||
name: 'fresh-local-snapshot',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
type: 'ct',
|
||||
vmid: 310,
|
||||
time: '2026-10-03T11:30:00Z',
|
||||
vmstate: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const pbs = resource('pbs-archive', 'pbs', {
|
||||
platformType: 'proxmox-pbs',
|
||||
cpu: { current: 14 },
|
||||
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
|
||||
pbs: {
|
||||
instance: 'pbs-archive',
|
||||
datastores: [
|
||||
{
|
||||
name: 'archive',
|
||||
total: 2e12,
|
||||
used: 1.18e12,
|
||||
free: 0.82e12,
|
||||
usage: 59,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const node = resource('pve-edge', 'node', {
|
||||
cpu: { current: 12, cores: 4 },
|
||||
memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 },
|
||||
disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 },
|
||||
proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 },
|
||||
});
|
||||
function Fixture() {
|
||||
const [view, setView] = createSignal('Pools');
|
||||
const [disk, setDisk] = createSignal<string | null>(null);
|
||||
const [pool, setPool] = createSignal<string | null>(null);
|
||||
const [expanded, setExpanded] = createSignal(new Set<string>());
|
||||
const ws = {
|
||||
state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] },
|
||||
activeAlerts: () => ({}),
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
};
|
||||
return (
|
||||
<WebSocketContext.Provider value={ws as any}>
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<ToastContainer />
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1 class="text-lg font-semibold">Operator tables and rollback consent</h1>
|
||||
<nav aria-label="Fixture views" class="flex flex-wrap gap-2">
|
||||
{['Pools', 'Disks', 'Controllers', 'Backups', 'Consent'].map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-11 rounded border border-border px-3"
|
||||
onClick={() => setView(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<Show when={view() === 'Pools'}>
|
||||
<section aria-label="Pool table">
|
||||
<StoragePoolsTable
|
||||
groupedRecords={groupStorageRecords(pools, 'none')}
|
||||
groupBy="none"
|
||||
expandedGroups={new Set(['All'])}
|
||||
toggleGroup={() => {}}
|
||||
expandedPoolId={pool()}
|
||||
setExpandedPoolId={setPool}
|
||||
storageGrowthBySeriesId={new Map()}
|
||||
storageGrowthColumnLabel="Growth"
|
||||
physicalDisks={[]}
|
||||
nodeOnlineByLabel={new Map()}
|
||||
highlightedRecordId={null}
|
||||
getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE}
|
||||
isLoading={false}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Disks'}>
|
||||
<section aria-label="Disk table">
|
||||
<DiskList
|
||||
disks={disks}
|
||||
nodes={[]}
|
||||
selectedNode={null}
|
||||
searchTerm=""
|
||||
selectedDiskId={disk()}
|
||||
onSelectedDiskChange={setDisk}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Controllers'}>
|
||||
<section aria-label="Controller table">
|
||||
<KubernetesControllersTable
|
||||
resources={controllers}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Backups'}>
|
||||
<section aria-label="Backup tables" class="space-y-4">
|
||||
<h2>Coverage</h2>
|
||||
<ProxmoxCoverageTable
|
||||
rows={model.coverageRows}
|
||||
hasAnyRows
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No backups"
|
||||
emptyDescription="No backups"
|
||||
sortKey={() => 'posture'}
|
||||
sortDirection={() => 'asc'}
|
||||
onSort={() => {}}
|
||||
expandedKeys={expanded()}
|
||||
onToggleExpand={(key) =>
|
||||
setExpanded((current) =>
|
||||
current.has(key)
|
||||
? new Set([...current].filter((item) => item !== key))
|
||||
: new Set([...current, key]),
|
||||
)
|
||||
}
|
||||
showPbsColumn
|
||||
showArchiveColumn
|
||||
showSnapshotColumn
|
||||
showTaskColumn={false}
|
||||
/>
|
||||
<h2>By date</h2>
|
||||
<ProxmoxRecoverableTable
|
||||
artifacts={model.recoverableArtifacts}
|
||||
hasAnyArtifacts
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No artifacts"
|
||||
emptyDescription="No artifacts"
|
||||
sortKey={() => 'created'}
|
||||
sortDirection={() => 'desc'}
|
||||
onSort={() => {}}
|
||||
sizeMaxBytes={1048576}
|
||||
/>
|
||||
<h2>Backup servers</h2>
|
||||
<ProxmoxBackupServersTable servers={[pbs]} />
|
||||
<h2>Proxmox nodes</h2>
|
||||
<ProxmoxNodesTable
|
||||
nodes={[node]}
|
||||
guests={[workload]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No nodes"
|
||||
emptyDescription="No nodes"
|
||||
/>
|
||||
<h2>Workload row</h2>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<GuestRow
|
||||
guest={
|
||||
{
|
||||
id: 'ct-310',
|
||||
vmid: 310,
|
||||
name: 'artifact-cache-310',
|
||||
node: 'pve-edge',
|
||||
instance: 'homelab',
|
||||
status: 'running',
|
||||
type: 'lxc',
|
||||
cpu: 0.12,
|
||||
cpus: 2,
|
||||
memory: { usage: 30, total: 1e9, used: 0.3e9, free: 0.7e9 },
|
||||
disk: { usage: 20, total: 1e10, used: 2e9, free: 8e9 },
|
||||
uptime: 86400,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: null,
|
||||
lock: '',
|
||||
lastSeen: new Date(now).toISOString(),
|
||||
} as any
|
||||
}
|
||||
visibleColumnIds={['name', 'cpu', 'memory', 'disk', 'uptime']}
|
||||
workloadTableLayoutMode={innerWidth < 440 ? 'phone' : 'compact'}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={view() === 'Consent'}>
|
||||
<section aria-label="Rollback history">
|
||||
<UpdateHistorySection />
|
||||
</section>
|
||||
</Show>
|
||||
</main>
|
||||
</DarkModeContext.Provider>
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
}
|
||||
void updateStore.checkForUpdates();
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,23 +1,67 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
|
||||
"verified_at": "2026-10-03T04:13:25.499572Z",
|
||||
"base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4",
|
||||
"verified_at": "2026-10-03T06:30:17.630790Z",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
|
||||
"frontend-modern/src/components/Settings/UpdateHistorySection.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx",
|
||||
"frontend-modern/src/components/Storage/EnhancedStorageBar.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx",
|
||||
"frontend-modern/src/components/Storage/useDiskDetailModel.ts",
|
||||
"frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/rowPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storageBarPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storagePagePresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51",
|
||||
"frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "39e7f5daa49bd18fd71d91a09a06da47ec3abc5496108d3715020180ca439e52",
|
||||
"frontend-modern/src/components/Storage/DiskDetail.tsx": "04e5bacb4900cc77a94fdb804363048ed53f4b84dc1c0e9610878a106a1db1a6",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx": "47da15179f6c7e98583d9db2a858790233660f8dc4eb9ea85681f262b50514f5",
|
||||
"frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "ed72a33e754e96ebd03374c34f9732b02411cd9055667f4e0051d6f7e66ab653",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx": "09417822bd543ddbc312d0c15a4ea74971066d6623d421d889858e56262b9d3d",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "7b61e9fac5e3a9fbae831a3cf907a71f1be7158f06bfe7a9fe7d16fce7a2c928",
|
||||
"frontend-modern/src/components/Storage/useDiskDetailModel.ts": "ee5059bb3a7e0069561e4f103e2fd89101e096ad32b3b27c6f4777c26c1388f8",
|
||||
"frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts": "d03daecfe22ede32e46edc74a20e4ac11d732d4f9ca202c0b6f36c22a590aa44",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639",
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "adbae773cc485f5dc16d1d316bbd5e1b1ace6be24404861ce02bf7dd8890bb25",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "786be66c40d33006edc1eda234df083ee68e0296103882ff108711026dd2ea29",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "6a546a5c1e8a9bbe6a2bdeac61c988a8e9f35cf96888adff245a5efd7017769e",
|
||||
"frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "fdc9fc2720ed04771f5c9d271f8972a4dece25e165064183fcf59678466a970e",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "dc2641e74f12ed01b5e2a1424f2223c8ca136fa4e0321dd49a79e5ce458fc3cd",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "2ab0f6c58e18337f05ea85082ed5c465a625c5388ff82987eec41bbbe3005915",
|
||||
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "4aab84754245dda70c3b3be3e81b15875a8664d38c8d0325329b2074b6c26fd5",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "4366372e6acf67ac62e03a2c9c34e599d304724a1c79e17b60503bdae9aa6fe3",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "fc637ecfee6ffff80ee826bdb8ff546ef0dd8ffa5cd0790c19b1fdcf71dcfb65",
|
||||
"frontend-modern/src/features/storageBackups/rowPresentation.ts": "cabd37e3f736467aecff68359fcb97be08bcabbe5c5ec6e0a0da2fe67d2e3261",
|
||||
"frontend-modern/src/features/storageBackups/storageBarPresentation.ts": "562eac2212378e98823717f712c791133cebf1f44bd71c75c7db097b244f6cc0",
|
||||
"frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "2a93a0176b912283c77b384ee2c66c4bc62c21a04b645eb93917a41740ad2d6e"
|
||||
},
|
||||
"routes": [
|
||||
"Existing PBS/resource detail Overview change history",
|
||||
"Existing resource detail Manage action history"
|
||||
"Existing storage pool and physical-disk tables / disk expansion",
|
||||
"Existing Kubernetes controller table / production resource summary expansion",
|
||||
"Existing Proxmox coverage, By date, PBS/node tables and guest row",
|
||||
"Existing Settings Update History confirmation"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -27,26 +71,31 @@
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 768,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
|
||||
"Denied filtered facets do not fall back to the unfiltered bundle",
|
||||
"Retry retains denial while pending; fresh successful response restores events and clears guidance",
|
||||
"First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
|
||||
"Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
|
||||
"All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
|
||||
"Completed backup stays 13d old alongside a separate fresh 30m guest snapshot; missing independent backup remains unit-covered",
|
||||
"Structured ZFS scrub/resilver activity badges render at desktop/intermediate width; phone retains Degraded/Online and measured 59% capacity",
|
||||
"Disk table verdict keeps full SMART reason in the expanded header without phone overflow",
|
||||
"Job and CronJob absolute timestamps, elapsed duration, target, schedule, suspension and last-success remain reachable from phone row expansion",
|
||||
"Consent names running and selected historical versions separately and declares absent restore scope; legacy and installation-only effects are distinguished",
|
||||
"Rejected rollback keeps consent and error toast; pending actions cannot be duplicated or cancelled",
|
||||
"All sixteen final screenshots inspected; Playwright 1.56.1 matches integration lock, light desktop/intermediate Chromium and dark touch phone WebKit"
|
||||
],
|
||||
"interactions": [
|
||||
"Remount full production resource drawer with denied/transient reads",
|
||||
"Activate scoped Retry with keyboard Enter",
|
||||
"Apply and clear Change kind filter",
|
||||
"Switch between Overview and Manage"
|
||||
"Expand/collapse real resource and disk rows",
|
||||
"Open consent using keyboard Enter, cancel and return focus without a rollback write",
|
||||
"Reject one synthetic rollback POST, then hold/accept a second while checking disabled duplicate/cancel and Escape behaviour",
|
||||
"Select existing backup projections with independent archive and guest snapshot dates"
|
||||
],
|
||||
"limitations": [
|
||||
"Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
|
||||
"401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
|
||||
"Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
|
||||
"Independent reader authorisation remains independent; this is not a global session revocation broadcast."
|
||||
"Synthetic observations and HTTP responses through production components and CSS in a local Vite preview. Not native appliance data/authorisation, reporter cause, released availability, actual rollback, Tailscale or SSE acceptance.",
|
||||
"The History API exposes no restore-scope metadata; no version heuristic or free-form note establishes scope. The UI conservatively says it is unknown.",
|
||||
"Full provider identifiers remain titles/search targets. Some long row names and secondary intermediate-width fields intentionally truncate; this does not prove every native estate or every value fits.",
|
||||
"The frontend graph is unchanged and its seven high GHSA-vfj7-8cjw-p6xm findings remain; browser/component/build proof is not audit clearance."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges';
|
||||
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
|
||||
import { formatInteger } from './resourceDetailMappers';
|
||||
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
|
||||
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
||||
|
||||
interface ResourceSummaryPresentationProps {
|
||||
@@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
|
||||
}
|
||||
: null,
|
||||
props.content !== 'overview' && docker ? dockerSection(docker) : null,
|
||||
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource) : null,
|
||||
props.content !== 'overview'
|
||||
? {
|
||||
label: 'Identity',
|
||||
|
||||
+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 {
|
||||
buildKubernetesControllerSection,
|
||||
buildKubernetesDetailSections,
|
||||
buildKubernetesDetailsSummary,
|
||||
hasKubernetesDetailSections,
|
||||
@@ -177,3 +179,203 @@ describe('buildKubernetesDetailsSummary', () => {
|
||||
).toBe('https://prod.k8s.local:6443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKubernetesControllerSection', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const absolute = (timestamp: string): string =>
|
||||
formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } });
|
||||
|
||||
it('returns nothing for resources that are not workload controllers', () => {
|
||||
expect(
|
||||
buildKubernetesControllerSection(
|
||||
makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull();
|
||||
expect(
|
||||
buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('leads a Job with its absolute start and completion times, then the counts', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
desiredReplicas: 10,
|
||||
active: 1,
|
||||
succeeded: 8,
|
||||
failed: 2,
|
||||
startTime: '2026-05-24T12:55:00Z',
|
||||
completionTime: '2026-05-24T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.label).toBe('Job');
|
||||
expect(section?.testId).toBe('resource-kubernetes-controller-section');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true },
|
||||
{ label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true },
|
||||
{ label: 'Duration', value: '5m' },
|
||||
{ label: 'Target', value: '10 completions' },
|
||||
{ label: 'Active', value: '1' },
|
||||
{ label: 'Succeeded', value: '8' },
|
||||
{ label: 'Failed', value: '2', tone: 'danger' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a running Job on its start time without a completion or duration row', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
namespace: 'batch',
|
||||
desiredReplicas: 1,
|
||||
active: 1,
|
||||
failed: 0,
|
||||
startTime: '2026-05-24T13:29:30Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.rows.map((row) => row.label)).toEqual([
|
||||
'Started',
|
||||
'Target',
|
||||
'Active',
|
||||
'Failed',
|
||||
'Namespace',
|
||||
]);
|
||||
expect(section?.rows[0]).toEqual({
|
||||
label: 'Started',
|
||||
value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`,
|
||||
wrap: true,
|
||||
});
|
||||
expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' });
|
||||
});
|
||||
|
||||
it('leads a CronJob with its schedule and the last run and last success times', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'billing-rollup',
|
||||
type: 'k8s-cronjob',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
schedule: '0 2 * * *',
|
||||
suspend: true,
|
||||
active: 0,
|
||||
lastScheduleTime: '2026-05-24T02:00:00Z',
|
||||
lastSuccessfulTime: '2026-05-23T02:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(section?.label).toBe('CronJob');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' },
|
||||
{ label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true },
|
||||
{ label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
|
||||
{ label: 'Suspended', value: 'Yes', tone: 'warning' },
|
||||
{ label: 'Active', value: '0' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the service name and counts for StatefulSets and DaemonSets', () => {
|
||||
const statefulSet = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout-api-stateful',
|
||||
type: 'k8s-statefulset',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
desiredReplicas: 3,
|
||||
currentReplicas: 3,
|
||||
readyReplicas: 2,
|
||||
availableReplicas: 2,
|
||||
updatedReplicas: 2,
|
||||
serviceName: 'checkout-headless',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(statefulSet?.label).toBe('StatefulSet');
|
||||
expect(statefulSet?.rows).toEqual([
|
||||
{ label: 'Service', value: 'checkout-headless' },
|
||||
{ label: 'Target', value: '3 pods' },
|
||||
{ label: 'Current', value: '3' },
|
||||
{ label: 'Ready', value: '2' },
|
||||
{ label: 'Available', value: '2' },
|
||||
{ label: 'Updated', value: '2' },
|
||||
{ label: 'Namespace', value: 'apps' },
|
||||
]);
|
||||
|
||||
const daemonSet = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'node-exporter',
|
||||
type: 'k8s-daemonset',
|
||||
kubernetes: {
|
||||
namespace: 'observability',
|
||||
desiredNumberScheduled: 6,
|
||||
currentNumberScheduled: 6,
|
||||
numberReady: 5,
|
||||
numberAvailable: 5,
|
||||
numberUnavailable: 1,
|
||||
numberMisscheduled: 0,
|
||||
updatedReplicas: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(daemonSet?.label).toBe('DaemonSet');
|
||||
expect(daemonSet?.rows).toEqual([
|
||||
{ label: 'Target', value: '6 nodes' },
|
||||
{ label: 'Current', value: '6' },
|
||||
{ label: 'Ready', value: '5' },
|
||||
{ label: 'Available', value: '5' },
|
||||
{ label: 'Updated', value: '5' },
|
||||
{ label: 'Unavailable', value: '1', tone: 'warning' },
|
||||
{ label: 'Misscheduled', value: '0', tone: 'default' },
|
||||
{ label: 'Namespace', value: 'observability' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout-api-replicaset',
|
||||
type: 'k8s-replicaset',
|
||||
kubernetes: {
|
||||
desiredReplicas: 4,
|
||||
readyReplicas: 3,
|
||||
fullyLabeledReplicas: 4,
|
||||
observedGeneration: 7,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('ReplicaSet');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Target', value: '4 pods' },
|
||||
{ label: 'Ready', value: '3' },
|
||||
{ label: 'Fully labeled', value: '4' },
|
||||
{ label: 'Observed generation', value: '7' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,18 @@ import {
|
||||
compactDetailRows as compactRows,
|
||||
compactDetailSections as compactSections,
|
||||
formatDetailBytesValue,
|
||||
formatDetailCountValue,
|
||||
formatDetailIntegerValue,
|
||||
makeDetailRow as makeRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import {
|
||||
formatPlatformTableDateTimeValue,
|
||||
formatPlatformTableDurationValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
||||
|
||||
@@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = (
|
||||
return compactSections(sections);
|
||||
};
|
||||
|
||||
const countRow = (
|
||||
label: string,
|
||||
value: number | undefined,
|
||||
options: { noun?: string; tone?: (count: number) => DetailValueTone } = {},
|
||||
): DetailRow | null => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
const text = options.noun
|
||||
? formatDetailCountValue(value, options.noun)
|
||||
: formatDetailIntegerValue(value);
|
||||
return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {});
|
||||
};
|
||||
|
||||
const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
|
||||
const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
|
||||
|
||||
// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
|
||||
// only has room for an age, so the expansion carries the absolute time first
|
||||
// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps
|
||||
// because a phone-width detail cell is too narrow for both on one line.
|
||||
const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => {
|
||||
const raw = asString(timestamp);
|
||||
if (!raw) return null;
|
||||
const absolute = formatPlatformTableDateTimeValue(raw, {
|
||||
emptyText: '',
|
||||
dateTimeFormat: { year: 'numeric' },
|
||||
});
|
||||
if (!absolute) return makeRow(label, raw, { wrap: true });
|
||||
const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' });
|
||||
return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true });
|
||||
};
|
||||
|
||||
const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => {
|
||||
const startedAt = Date.parse(asString(start) ?? '');
|
||||
const endedAt = Date.parse(asString(end) ?? '');
|
||||
if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) {
|
||||
return undefined;
|
||||
}
|
||||
return (endedAt - startedAt) / 1000;
|
||||
};
|
||||
|
||||
type ControllerRows = { label: string; rows: Array<DetailRow | null> };
|
||||
|
||||
// Each kind leads with what its narrow table row drops: the Detail column
|
||||
// (service name, timestamps) below the large layout and Target on a phone.
|
||||
const controllerRows = (resource: Resource): ControllerRows | null => {
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return null;
|
||||
switch (resource.type) {
|
||||
case 'k8s-replicaset':
|
||||
return {
|
||||
label: 'ReplicaSet',
|
||||
rows: [
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
countRow('Current', k.currentReplicas),
|
||||
countRow('Ready', k.readyReplicas),
|
||||
countRow('Available', k.availableReplicas),
|
||||
countRow('Fully labeled', k.fullyLabeledReplicas),
|
||||
countRow('Observed generation', k.observedGeneration),
|
||||
],
|
||||
};
|
||||
case 'k8s-statefulset':
|
||||
return {
|
||||
label: 'StatefulSet',
|
||||
rows: [
|
||||
makeRow('Service', k.serviceName),
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
countRow('Current', k.currentReplicas),
|
||||
countRow('Ready', k.readyReplicas),
|
||||
countRow('Available', k.availableReplicas),
|
||||
countRow('Updated', k.updatedReplicas),
|
||||
],
|
||||
};
|
||||
case 'k8s-daemonset':
|
||||
return {
|
||||
label: 'DaemonSet',
|
||||
rows: [
|
||||
countRow('Target', k.desiredNumberScheduled, { noun: 'node' }),
|
||||
countRow('Current', k.currentNumberScheduled),
|
||||
countRow('Ready', k.numberReady),
|
||||
countRow('Available', k.numberAvailable),
|
||||
countRow('Updated', k.updatedReplicas),
|
||||
countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }),
|
||||
countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }),
|
||||
],
|
||||
};
|
||||
case 'k8s-job':
|
||||
return {
|
||||
label: 'Job',
|
||||
rows: [
|
||||
timestampRow('Started', k.startTime),
|
||||
timestampRow('Completed', k.completionTime),
|
||||
makeRow(
|
||||
'Duration',
|
||||
formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), {
|
||||
emptyText: '',
|
||||
}),
|
||||
),
|
||||
countRow('Target', k.desiredReplicas, { noun: 'completion' }),
|
||||
countRow('Active', k.active),
|
||||
countRow('Succeeded', k.succeeded),
|
||||
countRow('Failed', k.failed, { tone: dangerWhenPositive }),
|
||||
],
|
||||
};
|
||||
case 'k8s-cronjob':
|
||||
return {
|
||||
label: 'CronJob',
|
||||
rows: [
|
||||
makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }),
|
||||
timestampRow('Last run', k.lastScheduleTime),
|
||||
timestampRow('Last success', k.lastSuccessfulTime),
|
||||
makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }),
|
||||
countRow('Active', k.active),
|
||||
],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Workload-controller facts for the always-visible summary of the resource
|
||||
// drawer, the same slot Docker containers use for their timestamps. The
|
||||
// controllers table hides Scope and Detail below its large layout and Target
|
||||
// on a phone, so without this a Job's completion time or a CronJob's last
|
||||
// success is unreachable from a narrow row.
|
||||
export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => {
|
||||
const controller = controllerRows(resource);
|
||||
if (!controller) return null;
|
||||
const rows = compactRows([
|
||||
...controller.rows,
|
||||
makeRow('Namespace', resource.kubernetes?.namespace),
|
||||
makeRow('Cluster', resource.kubernetes?.clusterName),
|
||||
]);
|
||||
if (rows.length === 0) return null;
|
||||
return {
|
||||
label: controller.label,
|
||||
rows,
|
||||
testId: 'resource-kubernetes-controller-section',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildKubernetesDetailsSummary = (resource: Resource): string | null => {
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
@@ -45,7 +46,7 @@ const statusPresentation = (status: string): { label: string; className: string
|
||||
}
|
||||
};
|
||||
|
||||
// A rollback restores the backup taken before this update was applied, so it
|
||||
// A rollback uses the backup taken before this update was applied, so it
|
||||
// is only offered where that backup is still retained on disk (the backend
|
||||
// clears backup_path when retention prunes it) and the update actually landed.
|
||||
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
|
||||
@@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => {
|
||||
Update History
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Updates applied through Pulse, with rollback to the backup taken before each one.
|
||||
Updates applied through Pulse, with version rollback using a retained backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
|
||||
<p>
|
||||
This restores the binary, configuration, and data from the backup taken before the
|
||||
update to {formatVersionLabel(entry().version_to)} on{' '}
|
||||
{formatTimestamp(entry().timestamp)}.
|
||||
<p class="text-muted">
|
||||
Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.
|
||||
</p>
|
||||
<p>
|
||||
Settings and alert changes made since that backup will be reverted, and Pulse will
|
||||
restart to complete the rollback.
|
||||
This requests an installation rollback using the backup taken before the update to{' '}
|
||||
{formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}.
|
||||
Pulse will restart if the rollback succeeds.
|
||||
</p>
|
||||
<p>
|
||||
Restore scope is not reported for this backup. A scope-aware server keeps active
|
||||
runtime data in place and restores only the binary and independent installation
|
||||
files. Older recovery can also replace install-local data and configuration,
|
||||
reverting later changes.
|
||||
</p>
|
||||
<p>
|
||||
Do not assume this will undo settings or alerts. Full-state recovery requires
|
||||
Pulse to be stopped and a consistent backup of every active store.{' '}
|
||||
<a
|
||||
class="text-primary underline"
|
||||
href={`${getShippedDocUrl('AUTO_UPDATE.md')}#manual-rollback`}
|
||||
>
|
||||
Read the recovery instructions
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates';
|
||||
|
||||
const mockListUpdateHistory = vi.fn();
|
||||
const mockStoreRollbackUpdate = vi.fn();
|
||||
let runningVersion: string | undefined = '6.0.5';
|
||||
|
||||
vi.mock('@/api/updates', () => ({
|
||||
UpdatesAPI: {
|
||||
@@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({
|
||||
|
||||
vi.mock('@/stores/updates', () => ({
|
||||
updateStore: {
|
||||
versionInfo: () => ({ version: '6.0.5' }),
|
||||
versionInfo: () => (runningVersion ? { version: runningVersion } : null),
|
||||
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
|
||||
},
|
||||
}));
|
||||
@@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => {
|
||||
beforeEach(() => {
|
||||
mockListUpdateHistory.mockReset();
|
||||
mockStoreRollbackUpdate.mockReset();
|
||||
runningVersion = '6.0.5';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
|
||||
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent(
|
||||
'A scope-aware server keeps active runtime data in place',
|
||||
);
|
||||
expect(screen.getByText(/Older recovery can also/)).toHaveTextContent(
|
||||
'reverting later changes',
|
||||
);
|
||||
expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent(
|
||||
'Full-state recovery requires Pulse to be stopped',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/AUTO_UPDATE#manual-rollback',
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/Settings and alert changes made since that backup will be reverted/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
|
||||
@@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => {
|
||||
expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes the running version from an older history entry without guessing restore scope', async () => {
|
||||
runningVersion = 'v6.6.0-rc.1';
|
||||
mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent(
|
||||
'before the update to v6.0.5',
|
||||
);
|
||||
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('restore everything')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(mockStoreRollbackUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not infer the running version from the selected update when version information is missing', async () => {
|
||||
runningVersion = undefined;
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retains the consent and refreshes history after a rejected rollback', async () => {
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
mockStoreRollbackUpdate.mockResolvedValue(false);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported');
|
||||
expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('prevents duplicate requests and cancellation while the rollback request is pending', async () => {
|
||||
mockListUpdateHistory.mockResolvedValue([baseEntry]);
|
||||
let finish!: (accepted: boolean) => void;
|
||||
mockStoreRollbackUpdate.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(() => <UpdateHistorySection />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
|
||||
expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
finish(true);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
resolveHistoryRangeWithinLimit,
|
||||
} from '@/components/Storage/historyRangeAccess';
|
||||
import {
|
||||
DISK_DETAIL_HEADER_STACK_CLASS,
|
||||
DISK_DETAIL_HEALTH_LABEL_CLASS,
|
||||
DISK_DETAIL_HEALTH_ROW_CLASS,
|
||||
DISK_DETAIL_HEALTH_SUMMARY_CLASS,
|
||||
DISK_DETAIL_HISTORY_RANGE_OPTIONS,
|
||||
DISK_DETAIL_LIVE_CHARTS,
|
||||
getDiskAttributeValueTextClass,
|
||||
@@ -55,6 +59,7 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
||||
historyResourceId,
|
||||
attributeCards,
|
||||
historyCharts,
|
||||
healthPresentation,
|
||||
metricResourceId,
|
||||
collectionMessages,
|
||||
liveIOAvailable,
|
||||
@@ -91,19 +96,32 @@ export const DiskDetail: Component<DiskDetailProps> = (props) => {
|
||||
return (
|
||||
<div class={STORAGE_DISK_DETAIL_ROOT_CLASS}>
|
||||
<div class={STORAGE_DISK_DETAIL_HEADER_CLASS}>
|
||||
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
||||
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
|
||||
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
||||
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
||||
<span
|
||||
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
||||
title="Data source"
|
||||
>
|
||||
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
||||
</span>
|
||||
<Show when={diskData().serial}>
|
||||
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
|
||||
</Show>
|
||||
<div class={DISK_DETAIL_HEADER_STACK_CLASS}>
|
||||
<div class={STORAGE_DETAIL_META_ROW_CLASS}>
|
||||
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>
|
||||
{diskData().model || 'Unknown Disk'}
|
||||
</span>
|
||||
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
|
||||
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
|
||||
<span
|
||||
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
|
||||
title="Data source"
|
||||
>
|
||||
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
|
||||
</span>
|
||||
<Show when={diskData().serial}>
|
||||
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
|
||||
</Show>
|
||||
</div>
|
||||
{/* The verdict the row shows as a single word, with its reason in full. */}
|
||||
<div class={DISK_DETAIL_HEALTH_ROW_CLASS} data-testid="disk-detail-health">
|
||||
<span class={`${DISK_DETAIL_HEALTH_LABEL_CLASS} ${healthPresentation().tone}`}>
|
||||
{healthPresentation().label}
|
||||
</span>
|
||||
<Show when={healthPresentation().summary}>
|
||||
<span class={DISK_DETAIL_HEALTH_SUMMARY_CLASS}>{healthPresentation().summary}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
PHYSICAL_DISK_HEADER_SIZE_CLASS,
|
||||
PHYSICAL_DISK_HEADER_TEMP_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_LABEL_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_SUMMARY_CLASS,
|
||||
PHYSICAL_DISK_HEALTH_WRAP_CLASS,
|
||||
PHYSICAL_DISK_DEVICE_TEXT_CLASS,
|
||||
PHYSICAL_DISK_LIFE_CLASS,
|
||||
@@ -72,8 +71,10 @@ import {
|
||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_STYLE,
|
||||
getPhysicalDiskColumnWidthPercent,
|
||||
getPhysicalDiskCellPaddingClass,
|
||||
getPhysicalDiskColumnWidthStyle,
|
||||
getPhysicalDiskEmptyStatePresentation,
|
||||
getPhysicalDiskHealthCompactLabel,
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
getPhysicalDiskHostLabel,
|
||||
@@ -117,11 +118,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
baseClass: string,
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
visibleClass: 'table-column' | 'table-cell',
|
||||
) =>
|
||||
`${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim();
|
||||
const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({
|
||||
width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`,
|
||||
});
|
||||
) => {
|
||||
const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId)
|
||||
? visibleClass
|
||||
: 'hidden';
|
||||
// Rendered cells and headers shed the shared primitives' desktop padding
|
||||
// on the phone layouts; `<col>` elements carry no padding.
|
||||
const padding =
|
||||
visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : '';
|
||||
return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
const columnStyle = (columnId: PhysicalDiskTableColumnId) =>
|
||||
getPhysicalDiskColumnWidthStyle(layoutMode(), columnId);
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
const model = useDiskListModel({
|
||||
disks: () => props.disks,
|
||||
@@ -327,6 +335,11 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
const status = createMemo(() => getPhysicalDiskHealthStatus(data()));
|
||||
const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk));
|
||||
const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status()));
|
||||
const healthCompactLabel = createMemo(() =>
|
||||
getPhysicalDiskHealthCompactLabel(status().label),
|
||||
);
|
||||
const healthLabelClass = () =>
|
||||
`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`;
|
||||
const isSelected = () => model.selectedDisk()?.id === disk.id;
|
||||
const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk));
|
||||
const isSummaryHighlighted = () =>
|
||||
@@ -446,15 +459,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
data-storage-column="health"
|
||||
>
|
||||
<div class={PHYSICAL_DISK_HEALTH_WRAP_CLASS}>
|
||||
<span class={`${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`}>
|
||||
{status().label}
|
||||
</span>
|
||||
<Show when={healthSummary()}>
|
||||
{/* The row shows the verdict word only; the reason is the
|
||||
cell's title here and is spelled out in the disk drawer.
|
||||
The phone projection of the table container shows the
|
||||
shorter word; the tone stays on each span so the rendered
|
||||
label is the styled element in either projection. */}
|
||||
<Show
|
||||
when={healthCompactLabel() !== status().label}
|
||||
fallback={
|
||||
<span class={healthLabelClass()} title={healthSummary() || undefined}>
|
||||
{status().label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={PHYSICAL_DISK_HEALTH_SUMMARY_CLASS}
|
||||
title={healthSummary()}
|
||||
class={`${healthLabelClass()} platform-table-label-compact`}
|
||||
title={healthSummary() || undefined}
|
||||
>
|
||||
{healthSummary()}
|
||||
{healthCompactLabel()}
|
||||
</span>
|
||||
<span
|
||||
class={`${healthLabelClass()} platform-table-label-full`}
|
||||
title={healthSummary() || undefined}
|
||||
>
|
||||
{status().label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getStorageBarTooltipTitle,
|
||||
getStorageBarZfsHeadingLabel,
|
||||
} from '@/features/storageBackups/storageBarPresentation';
|
||||
import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage';
|
||||
import { useTooltip } from '@/hooks/useTooltip';
|
||||
import { ProgressBar } from '@/components/shared/ProgressBar';
|
||||
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
||||
@@ -42,13 +43,14 @@ interface EnhancedStorageBarProps {
|
||||
export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
||||
const tip = useTooltip();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({
|
||||
used: () => props.used,
|
||||
total: () => props.total,
|
||||
free: () => props.free,
|
||||
zfsPool: () => props.zfsPool,
|
||||
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
||||
});
|
||||
const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } =
|
||||
useEnhancedStorageBarModel({
|
||||
used: () => props.used,
|
||||
total: () => props.total,
|
||||
free: () => props.free,
|
||||
zfsPool: () => props.zfsPool,
|
||||
thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={STORAGE_BAR_ROOT_CLASS}>
|
||||
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) {
|
||||
}
|
||||
label={
|
||||
<span class={STORAGE_BAR_LABEL_WRAP_CLASS}>
|
||||
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>{label()}</span>
|
||||
<span class={STORAGE_BAR_LABEL_TEXT_CLASS}>
|
||||
{/* The phone projection of the table container keeps the
|
||||
percentage only; the used/total sublabel returns with the
|
||||
wider container and always lives in the tooltip. */}
|
||||
<PlatformResponsiveTableLabel compact={compactLabel()} full={label()} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getStoragePoolCellPaddingClass,
|
||||
isStoragePoolColumnVisible,
|
||||
type StoragePoolTableColumnId,
|
||||
type StoragePoolTableLayoutMode,
|
||||
@@ -62,7 +63,11 @@ interface StoragePoolRowProps {
|
||||
|
||||
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
||||
const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) =>
|
||||
`${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim();
|
||||
`${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${
|
||||
isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'
|
||||
}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
|
||||
const detailControlsId = createMemo(() =>
|
||||
buildSummaryDisclosureControlsId(props.summarySeriesId),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/components/shared/Table';
|
||||
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
getStoragePoolColumnWidthPercent,
|
||||
getStoragePoolColumnWidthStyle,
|
||||
getStoragePoolTableColumns,
|
||||
getStoragePoolTableLayoutModeForContainer,
|
||||
getStorageEmptyStateMessage,
|
||||
@@ -106,9 +106,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
|
||||
{(column) => (
|
||||
<col
|
||||
class={columnClass(column.colClassName, column.id, 'table-column')}
|
||||
style={{
|
||||
width: `${getStoragePoolColumnWidthPercent(layoutMode(), column.id)}%`,
|
||||
}}
|
||||
style={getStoragePoolColumnWidthStyle(layoutMode(), column.id)}
|
||||
data-storage-column={column.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal, type JSX } from 'solid-js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DiskDetail } from '@/components/Storage/DiskDetail';
|
||||
@@ -135,6 +135,33 @@ describe('DiskDetail', () => {
|
||||
expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the health verdict and its reason in the drawer header', () => {
|
||||
const disk = buildDisk();
|
||||
disk.physicalDisk!.health = 'FAILED';
|
||||
|
||||
render(() => <DiskDetail disk={disk} nodes={[]} />);
|
||||
|
||||
const health = screen.getByTestId('disk-detail-health');
|
||||
expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700');
|
||||
expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass(
|
||||
'min-w-0',
|
||||
'max-w-full',
|
||||
'whitespace-normal',
|
||||
'break-words',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the drawer header to the verdict alone when the disk is healthy', () => {
|
||||
const disk = buildDisk();
|
||||
disk.physicalDisk!.health = 'PASSED';
|
||||
|
||||
render(() => <DiskDetail disk={disk} nodes={[]} />);
|
||||
|
||||
const health = screen.getByTestId('disk-detail-health');
|
||||
expect(within(health).getByText('Healthy')).toBeInTheDocument();
|
||||
expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an explicit overview fallback when no detail readings are available', () => {
|
||||
const disk = buildDisk();
|
||||
delete disk.physicalDisk!.smart;
|
||||
|
||||
@@ -134,12 +134,18 @@ describe('DiskList', () => {
|
||||
'archive',
|
||||
'Cache Pool',
|
||||
'Archive Pool',
|
||||
'SMART failed.',
|
||||
'4%',
|
||||
'63°C',
|
||||
]) {
|
||||
expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument();
|
||||
}
|
||||
// The reason is not a second truncating string in the 32px row: it rides
|
||||
// on the verdict as its title and is spelled out in the disk drawer.
|
||||
expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument();
|
||||
expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute(
|
||||
'title',
|
||||
'SMART failed.',
|
||||
);
|
||||
expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Healthy')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('96%')).not.toBeInTheDocument();
|
||||
@@ -275,7 +281,13 @@ describe('DiskList', () => {
|
||||
expect(screen.getByText('Parity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tower Array')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument();
|
||||
// The reason stays one hover away on the verdict rather than as a second
|
||||
// truncating string in the row; the drawer spells it out.
|
||||
expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Needs Attention')).toHaveAttribute(
|
||||
'title',
|
||||
'Pending sectors detected.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => {
|
||||
|
||||
@@ -1154,10 +1154,11 @@ describe('Storage', () => {
|
||||
|
||||
render(() => <Storage />);
|
||||
|
||||
// zpool's upper-case state is presented titleized, like every other state.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute(
|
||||
expect(screen.getAllByText('Degraded')[0]).toHaveAttribute(
|
||||
'title',
|
||||
'1 read, 2 checksum errors',
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => {
|
||||
|
||||
expect(result.usagePercent()).toBe(40);
|
||||
expect(result.label()).toBe('40% (400 B/1000 B)');
|
||||
expect(result.compactLabel()).toBe('40%');
|
||||
expect(result.tooltipRows()).toEqual([
|
||||
{ label: 'Used', value: '400 B' },
|
||||
{ label: 'Free', value: '600 B' },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getDiskDetailAttributeCards,
|
||||
getDiskDetailHealthPresentation,
|
||||
getDiskDetailHistoryCharts,
|
||||
} from '@/features/storageBackups/diskDetailPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
@@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
|
||||
);
|
||||
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
|
||||
const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData()));
|
||||
const metricResourceId = createMemo(() => historyResourceId());
|
||||
const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData()));
|
||||
const liveIOAvailable = createMemo(() => {
|
||||
@@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
|
||||
historyResourceId,
|
||||
attributeCards,
|
||||
historyCharts,
|
||||
healthPresentation,
|
||||
metricResourceId,
|
||||
collectionMessages,
|
||||
liveIOAvailable,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api';
|
||||
import { getMetricColorClass } from '@/utils/metricThresholds';
|
||||
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
import {
|
||||
getStorageBarCompactLabel,
|
||||
getStorageBarLabel,
|
||||
getStorageBarTooltipRows,
|
||||
getStorageBarUsagePercent,
|
||||
@@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
||||
getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()),
|
||||
);
|
||||
const label = createMemo(() => getStorageBarLabel(options.used(), options.total()));
|
||||
const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total()));
|
||||
const tooltipRows = createMemo(() =>
|
||||
getStorageBarTooltipRows(options.used(), options.free(), options.total()),
|
||||
);
|
||||
@@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp
|
||||
usagePercent,
|
||||
barColor,
|
||||
label,
|
||||
compactLabel,
|
||||
tooltipRows,
|
||||
zfsSummary,
|
||||
};
|
||||
|
||||
@@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => {
|
||||
});
|
||||
|
||||
it('derives mobile overrides from the canonical guest column model', () => {
|
||||
// The phone name cell carries the toggle, status dot, and backup badge, so
|
||||
// it takes a wider anchor than the shared 30% and the metric chips share
|
||||
// the rest.
|
||||
expect(getGuestColumnStyle('name', true)).toEqual({
|
||||
width: '30%',
|
||||
'max-width': '30%',
|
||||
width: '38%',
|
||||
'max-width': '38%',
|
||||
});
|
||||
expect(getGuestColumnStyle('cpu', true)).toEqual({
|
||||
width: '11.3235%',
|
||||
'max-width': '11.3235%',
|
||||
width: '10.0294%',
|
||||
'max-width': '10.0294%',
|
||||
});
|
||||
expect(getGuestColumnStyle('availability', true)).toEqual({
|
||||
width: '7.2059%',
|
||||
'max-width': '7.2059%',
|
||||
width: '6.3824%',
|
||||
'max-width': '6.3824%',
|
||||
});
|
||||
expect(getGuestColumnStyle('type', true)).toEqual({
|
||||
width: '9.2647%',
|
||||
'max-width': '9.2647%',
|
||||
width: '8.2059%',
|
||||
'max-width': '8.2059%',
|
||||
});
|
||||
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' });
|
||||
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' });
|
||||
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
|
||||
});
|
||||
|
||||
|
||||
@@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({
|
||||
maxWidth: width,
|
||||
});
|
||||
|
||||
// The phone name cell carries the expansion toggle, status dot, and backup
|
||||
// badge before the name, so the shared 30% identity anchor left a 390px phone
|
||||
// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The
|
||||
// four metric chips beside it need far less than an even split, so the name
|
||||
// takes the same wider phone anchor the Proxmox host table uses.
|
||||
const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
|
||||
|
||||
// Responsive weights are normalized against the currently visible column set.
|
||||
// That keeps each workload view mode full-width without assuming one fixed set.
|
||||
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
@@ -548,9 +555,11 @@ const getResponsiveColumnOverride = (
|
||||
activeIds,
|
||||
layoutMode === 'narrow'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
: layoutMode === 'phone'
|
||||
? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'mobile'
|
||||
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
);
|
||||
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
formatPlatformTableDateTimeValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
formatPlatformTableTextValue,
|
||||
getPlatformResourceCountNoun,
|
||||
getPlatformTableCellClassForKind,
|
||||
PlatformTableShell,
|
||||
type PlatformTableSortValue,
|
||||
@@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string =>
|
||||
const controllerKind = (resource: Resource): string =>
|
||||
resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type;
|
||||
|
||||
const countedTarget = (count: number | undefined, noun: string): string => {
|
||||
const value = count ?? 0;
|
||||
return `${value} ${getPlatformResourceCountNoun(noun, value)}`;
|
||||
};
|
||||
|
||||
const targetValue = (resource: Resource): string => {
|
||||
switch (resource.type) {
|
||||
case 'k8s-replicaset':
|
||||
case 'k8s-statefulset':
|
||||
return `${resource.kubernetes?.desiredReplicas ?? 0} pods`;
|
||||
return countedTarget(resource.kubernetes?.desiredReplicas, 'pods');
|
||||
case 'k8s-daemonset':
|
||||
return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`;
|
||||
return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes');
|
||||
case 'k8s-job':
|
||||
return `${resource.kubernetes?.desiredReplicas ?? 0} completions`;
|
||||
return countedTarget(resource.kubernetes?.desiredReplicas, 'completions');
|
||||
case 'k8s-cronjob':
|
||||
return formatPlatformTableTextValue(resource.kubernetes?.schedule);
|
||||
default:
|
||||
@@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="kind"
|
||||
class="platform-table-mobile-w-15 md:w-[8%]"
|
||||
class="platform-table-mobile-w-25 md:w-[8%]"
|
||||
>
|
||||
Kind
|
||||
</PlatformSortableTableHead>
|
||||
@@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{
|
||||
>
|
||||
Scope
|
||||
</PlatformSortableTableHead>
|
||||
{/* The phone row keeps controller, kind, ready and issues. A
|
||||
15% kind track clipped "DaemonSet" and "StatefulSet" and the
|
||||
target track clipped "1 completions", so target moves to the
|
||||
row expansion, which also carries the schedule and job
|
||||
timestamps the narrower layouts drop. */}
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="target"
|
||||
class="platform-table-mobile-w-20 md:w-[12%]"
|
||||
class="platform-table-phone-hidden md:w-[12%]"
|
||||
>
|
||||
Target
|
||||
</PlatformSortableTableHead>
|
||||
@@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="exceptions"
|
||||
class="platform-table-mobile-w-20 md:w-[11%]"
|
||||
class="platform-table-mobile-w-30 md:w-[11%]"
|
||||
>
|
||||
<PlatformResponsiveTableLabel compact="Issues" full="Exceptions" />
|
||||
</PlatformSortableTableHead>
|
||||
@@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
|
||||
>
|
||||
<span class="inline-block max-w-[12rem] truncate" title={target()}>
|
||||
{target()}
|
||||
|
||||
+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';
|
||||
|
||||
vi.mock('@/api/resources', () => ({
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn().mockResolvedValue({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { KubernetesControllersTable } from '../KubernetesControllersTable';
|
||||
@@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => {
|
||||
expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => {
|
||||
render(() => (
|
||||
<KubernetesControllersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'node-exporter',
|
||||
type: 'k8s-daemonset',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'observability',
|
||||
resourceKind: 'DaemonSet',
|
||||
desiredNumberScheduled: 6,
|
||||
numberReady: 6,
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
// "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and
|
||||
// "1 completions" clipped in the target track, so the phone row widens kind
|
||||
// and issues and demotes target to the row expansion.
|
||||
expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25');
|
||||
expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden');
|
||||
expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden');
|
||||
expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30');
|
||||
expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30');
|
||||
expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15');
|
||||
expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden');
|
||||
});
|
||||
|
||||
it('shows the absolute Job and CronJob times in the row expansion', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
|
||||
render(() => (
|
||||
<KubernetesControllersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'nightly-import',
|
||||
type: 'k8s-job',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
resourceKind: 'Job',
|
||||
desiredReplicas: 10,
|
||||
active: 1,
|
||||
succeeded: 8,
|
||||
failed: 2,
|
||||
startTime: '2026-05-24T12:55:00Z',
|
||||
completionTime: '2026-05-24T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'billing-rollup',
|
||||
type: 'k8s-cronjob',
|
||||
kubernetes: {
|
||||
clusterName: 'prod',
|
||||
namespace: 'batch',
|
||||
resourceKind: 'CronJob',
|
||||
schedule: '*/5 * * * *',
|
||||
lastScheduleTime: '2026-05-24T13:25:00Z',
|
||||
lastSuccessfulTime: '2026-05-24T12:55:00Z',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No controllers"
|
||||
emptyDescription="No controllers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
// Tapping the row opens the expansion; the Detail column is hidden below a
|
||||
// large container, so this is where a phone or half-width pane reads the
|
||||
// completion time.
|
||||
fireEvent.click(screen.getByText('nightly-import'));
|
||||
const job = screen.getByTestId('resource-kubernetes-controller-section');
|
||||
expect(within(job).getByText('Started')).toBeInTheDocument();
|
||||
expect(
|
||||
within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(job).getByText('Completed')).toBeInTheDocument();
|
||||
expect(
|
||||
within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(job).getByText('Duration')).toBeInTheDocument();
|
||||
expect(within(job).getByText('5m')).toBeInTheDocument();
|
||||
expect(within(job).getByText('Target')).toBeInTheDocument();
|
||||
expect(within(job).getByText('10 completions')).toBeInTheDocument();
|
||||
expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2');
|
||||
|
||||
fireEvent.click(screen.getByText('billing-rollup'));
|
||||
const cron = screen.getByTestId('resource-kubernetes-controller-section');
|
||||
expect(within(cron).getByText('Schedule')).toBeInTheDocument();
|
||||
expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Last run')).toBeInTheDocument();
|
||||
expect(
|
||||
within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Last success')).toBeInTheDocument();
|
||||
expect(
|
||||
within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`),
|
||||
).toBeInTheDocument();
|
||||
expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch');
|
||||
});
|
||||
|
||||
it('falls back to start and last-run times when a Job or CronJob has not completed', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-24T13:31:00Z'));
|
||||
@@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
// A single desired completion reads as "1 completion", matching the
|
||||
// expansion's Target row.
|
||||
expect(screen.getByText('1 completion')).toBeInTheDocument();
|
||||
expect(screen.getByText('Started 1m ago')).toHaveAttribute(
|
||||
'title',
|
||||
`Started: ${absoluteTime('2026-05-24T13:29:30Z')}`,
|
||||
|
||||
@@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: {
|
||||
}
|
||||
header={
|
||||
<>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[15%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')}`}>
|
||||
<PlatformResponsiveTableLabel compact="Server" full="Backup server" />
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[10%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||
<PlatformResponsiveTableLabel compact="State" full="Status" />
|
||||
</TableHead>
|
||||
<Show when={columnVisible('version')}>
|
||||
@@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: {
|
||||
</TableHead>
|
||||
</Show>
|
||||
<Show when={columnVisible('datastore')}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-20 md:w-[13%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')}`}>
|
||||
<PlatformResponsiveTableLabel compact="Store" full="Datastore" />
|
||||
</TableHead>
|
||||
</Show>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[15%]`}
|
||||
>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')}`}>
|
||||
Used
|
||||
</TableHead>
|
||||
<Show when={columnVisible('backups')}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[8%]`}
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')}`}
|
||||
aria-label="Backups"
|
||||
title="Backups"
|
||||
>
|
||||
@@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: {
|
||||
>
|
||||
{(datastore) => (
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={usageVariant(pct())}
|
||||
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
||||
ariaHidden
|
||||
/>
|
||||
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={usageVariant(pct())}
|
||||
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
|
||||
ariaHidden
|
||||
/>
|
||||
</Show>
|
||||
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
|
||||
<PlatformTablePercentValue value={pct()} />
|
||||
</span>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
buildProxmoxBackupRecoveryModel,
|
||||
coverageRowMatchesSearch,
|
||||
isBackupArtifact,
|
||||
recoverableArtifactMatchesSearch,
|
||||
type RecoverableArtifact,
|
||||
type WorkloadCoverageRow,
|
||||
@@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{
|
||||
return recoveryModel().coverageRows.flatMap((row) => {
|
||||
const artifacts = row.artifacts.filter(locationMatches);
|
||||
if (artifacts.length === 0) return [];
|
||||
// Same rule as the model: running or failed artifacts stay listed but
|
||||
// never become a "latest" pointer, and snapshots never become the backup.
|
||||
const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
artifacts,
|
||||
latestRecovery: newestArtifact(artifacts),
|
||||
latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')),
|
||||
latestBackup: newestArtifact(completed.filter(isBackupArtifact)),
|
||||
latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')),
|
||||
latestArchive: newestArtifact(
|
||||
artifacts.filter((artifact) => artifact.sourceKind === 'archive'),
|
||||
completed.filter((artifact) => artifact.sourceKind === 'archive'),
|
||||
),
|
||||
latestSnapshot: newestArtifact(
|
||||
artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
),
|
||||
pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length,
|
||||
archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length,
|
||||
@@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
case 'workload':
|
||||
return cmpString(a.workload.label, b.workload.label, direction);
|
||||
case 'latest':
|
||||
return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction);
|
||||
return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction);
|
||||
case 'pbs':
|
||||
return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction);
|
||||
case 'archive':
|
||||
|
||||
@@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing
|
||||
// "None" instead of restating the source ("No PVE backup file"). The long form
|
||||
// truncated at most widths and stays available as the hover title.
|
||||
const COVERAGE_EMPTY_LABEL = 'None';
|
||||
const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file';
|
||||
// Matches the posture engine's own wording for a snapshot-only subject.
|
||||
const COVERAGE_SNAPSHOT_ONLY_TITLE =
|
||||
'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.';
|
||||
|
||||
const coveragePostureVariant = (
|
||||
posture: WorkloadCoverageRow['posture'],
|
||||
@@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: {
|
||||
headClass={getPlatformTableHeadClassForKind('text')}
|
||||
/>
|
||||
<SortableHead
|
||||
label={layoutMode() === 'compact' ? 'Age' : 'Restore'}
|
||||
label={
|
||||
layoutMode() === 'compact'
|
||||
? 'Age'
|
||||
: layoutMode() === 'basic'
|
||||
? 'Backup'
|
||||
: 'Last backup'
|
||||
}
|
||||
sortKey="latest"
|
||||
currentSort={props.sortKey}
|
||||
direction={props.sortDirection}
|
||||
@@ -407,14 +417,26 @@ export function ProxmoxCoverageTable(props: {
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={row.latestRecovery}
|
||||
when={row.latestBackup}
|
||||
fallback={
|
||||
<span class="text-muted" title="No restore point">
|
||||
<span
|
||||
class="text-muted"
|
||||
title={
|
||||
row.latestSnapshot
|
||||
? COVERAGE_SNAPSHOT_ONLY_TITLE
|
||||
: COVERAGE_NO_BACKUP_TITLE
|
||||
}
|
||||
>
|
||||
{COVERAGE_EMPTY_LABEL}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
{(artifact) => (
|
||||
<ProxmoxBackupAgeText
|
||||
artifact={artifact()}
|
||||
compact={layoutMode() === 'compact'}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<Show when={columnVisible('pbs')}>
|
||||
@@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(artifact) => <ProxmoxBackupAgeText artifact={artifact()} />}
|
||||
{(artifact) => (
|
||||
<ProxmoxBackupAgeText
|
||||
artifact={artifact()}
|
||||
compact={layoutMode() === 'compact'}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
@@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: {
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[24rem] truncate"
|
||||
title={artifact.detail}
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
{artifact.detail || '—'}
|
||||
</span>
|
||||
|
||||
@@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: {
|
||||
artifact.workload.name || artifact.workload.label,
|
||||
artifact.workload.typeLabel,
|
||||
artifact.workload.vmid,
|
||||
// The phone projection has no location column, so the row's
|
||||
// hover title carries the repository instead.
|
||||
columnVisible('location') ? undefined : artifact.location,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
|
||||
<ProxmoxBackupAgeText artifact={artifact} />
|
||||
<ProxmoxBackupAgeText artifact={artifact} compact={layoutMode() === 'compact'} />
|
||||
</TableCell>
|
||||
<Show when={columnVisible('size')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}>
|
||||
@@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: {
|
||||
</TableCell>
|
||||
<Show when={columnVisible('details')}>
|
||||
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
|
||||
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
|
||||
<span
|
||||
class="inline-block max-w-[20rem] truncate"
|
||||
title={artifact.detailTitle ?? artifact.detail}
|
||||
>
|
||||
{artifact.detail || '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
expect(headers).toContain('Target ID');
|
||||
expect(headers).toContain('Node');
|
||||
expect(headers).toContain('Posture▲');
|
||||
expect(headers).toContain('Restore');
|
||||
expect(headers).toContain('Last backup');
|
||||
expect(headers).toContain('PBS snapshot');
|
||||
expect(headers).toContain('Guest snapshot');
|
||||
expect(headers).not.toContain('PVE file');
|
||||
@@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => {
|
||||
// cannot truncate to "No PVE backup f…".
|
||||
const empty = (title: string) => document.querySelector(`td span[title="${title}"]`);
|
||||
for (const title of [
|
||||
'No restore point',
|
||||
'No PBS snapshot or PVE backup file',
|
||||
'No PBS snapshot',
|
||||
'No PVE backup file',
|
||||
'No guest snapshot',
|
||||
|
||||
@@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => {
|
||||
expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([
|
||||
'Workload',
|
||||
'Via',
|
||||
'Loc',
|
||||
'Age▼',
|
||||
'State',
|
||||
]);
|
||||
expect(document.body.textContent).toContain('VM 100');
|
||||
expect(document.body.textContent).toContain('main / pve1');
|
||||
// The phone projection has no location column; the row title carries it.
|
||||
expect(document.body.textContent).not.toContain('main / pve1');
|
||||
expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain('5 PBS files');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
|
||||
expect(model.coverageSummary.attention).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => {
|
||||
it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => {
|
||||
const postures = new Map([
|
||||
['vm-830', posture('vm-830', 'unprotected')],
|
||||
['vm-840', posture('vm-840', 'protected')],
|
||||
|
||||
@@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the archive format as its detail and keeps the volid on hover and in search', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [archive({ format: 'tar.zst' })],
|
||||
snapshots: [],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const [artifact] = model.recoverableArtifacts;
|
||||
expect(artifact.detail).toBe('tar.zst');
|
||||
expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
|
||||
expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the archive file name when the provider reports no format', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [archive({ format: '' })],
|
||||
snapshots: [],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst');
|
||||
});
|
||||
|
||||
it('keeps a terminally incomplete PBS artifact out of latest recovery', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
@@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => {
|
||||
|
||||
const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed'));
|
||||
expect(failed).toMatchObject({ failed: true, running: false, verified: undefined });
|
||||
expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z');
|
||||
expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z');
|
||||
expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a fresh guest snapshot from standing in for the last backup', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })],
|
||||
archives: [],
|
||||
snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const row = model.coverageRows[0];
|
||||
expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z');
|
||||
expect(row.latestBackup?.sourceKind).toBe('pbs');
|
||||
expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z');
|
||||
expect(
|
||||
getRecoveryAgeBand(
|
||||
row.latestBackup?.createdMs,
|
||||
model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'),
|
||||
),
|
||||
).toBe('aging');
|
||||
});
|
||||
|
||||
it('reports no last backup for a snapshot-only workload', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups: [],
|
||||
archives: [],
|
||||
snapshots: [snapshot()],
|
||||
tasks: [],
|
||||
nowMs: Date.parse('2026-05-26T08:00:00Z'),
|
||||
});
|
||||
|
||||
const row = model.coverageRows[0];
|
||||
expect(row.latestBackup).toBeUndefined();
|
||||
expect(row.latestSnapshot?.sourceKind).toBe('snapshot');
|
||||
expect(row.artifacts).toHaveLength(1);
|
||||
expect(model.coverageSummary.recoverableArtifacts).toBe(1);
|
||||
});
|
||||
|
||||
it('uses canonical workload attention while retaining the failed task as evidence', () => {
|
||||
const model = buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
|
||||
+3
-4
@@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
'status',
|
||||
'datastore',
|
||||
'used',
|
||||
'backups',
|
||||
]);
|
||||
expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' });
|
||||
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' });
|
||||
expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' });
|
||||
expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' });
|
||||
expect(ids(getBackupServerColumns('basic'))).toEqual([
|
||||
'server',
|
||||
'status',
|
||||
@@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => {
|
||||
expect(ids(getRecoverableColumns('compact'))).toEqual([
|
||||
'workload',
|
||||
'source',
|
||||
'location',
|
||||
'created',
|
||||
'state',
|
||||
]);
|
||||
expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' });
|
||||
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' });
|
||||
expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' });
|
||||
expect(ids(getRecoverableColumns('basic'))).toEqual([
|
||||
'workload',
|
||||
'source',
|
||||
|
||||
@@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra
|
||||
import {
|
||||
ArtifactSourceBadge,
|
||||
ArtifactStateBadge,
|
||||
ProxmoxBackupAgeText,
|
||||
ProxmoxBackupWorkloadTypeBadge,
|
||||
SortableHead,
|
||||
formatCompactBackupAge,
|
||||
} from '../proxmoxBackupsTableShared';
|
||||
import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel';
|
||||
|
||||
@@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => {
|
||||
const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString();
|
||||
expect(formatCompactBackupAge(createdAt)).toBe('18h');
|
||||
expect(formatCompactBackupAge(new Date().toISOString())).toBe('now');
|
||||
|
||||
const artifact = {
|
||||
id: 'pbs:1',
|
||||
nativeId: '1',
|
||||
sourceKind: 'pbs',
|
||||
sourceLabel: 'PBS',
|
||||
workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' },
|
||||
createdAt,
|
||||
createdMs: Date.parse(createdAt),
|
||||
location: 'main',
|
||||
detail: '',
|
||||
protected: false,
|
||||
} as RecoverableArtifact;
|
||||
render(() => <ProxmoxBackupAgeText artifact={artifact} compact />);
|
||||
const age = screen.getByText('18h');
|
||||
expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt);
|
||||
});
|
||||
|
||||
it('keeps backup age text on the shared relative-time primitive', () => {
|
||||
expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue');
|
||||
expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime(');
|
||||
|
||||
@@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => {
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
|
||||
});
|
||||
|
||||
it('shows the full operational metric track at phone width', () => {
|
||||
it('matches the guest table columns and keeps node names whole at phone width', () => {
|
||||
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
|
||||
const ids = columns.map((column) => column.id);
|
||||
|
||||
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
|
||||
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']);
|
||||
expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]');
|
||||
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' });
|
||||
});
|
||||
|
||||
it('prioritizes live utilization columns in the mobile host table', () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RecoverableArtifact {
|
||||
/** Operator-facing source/repository label used by the backup-location filter. */
|
||||
locationLabel?: string;
|
||||
detail: string;
|
||||
/** Full provider identifier behind a shortened `detail`, shown on hover. */
|
||||
detailTitle?: string;
|
||||
protected: boolean;
|
||||
verified?: boolean;
|
||||
/** A still-running backup is writing this artifact; it is not recoverable yet. */
|
||||
@@ -70,7 +72,13 @@ export interface WorkloadCoverageRow {
|
||||
key: string;
|
||||
workload: WorkloadReference;
|
||||
artifacts: RecoverableArtifact[];
|
||||
latestRecovery?: RecoverableArtifact;
|
||||
/**
|
||||
* Newest completed PBS snapshot or PVE backup file. Guest snapshots never
|
||||
* fill this slot: they live on the guest's own storage and the protection
|
||||
* posture engine does not count them as independent recovery, so a fresh
|
||||
* snapshot must not read as a fresh backup beside a stale posture.
|
||||
*/
|
||||
latestBackup?: RecoverableArtifact;
|
||||
latestPBS?: RecoverableArtifact;
|
||||
latestArchive?: RecoverableArtifact;
|
||||
latestSnapshot?: RecoverableArtifact;
|
||||
@@ -138,12 +146,25 @@ export function getRecoveryAgeBand(
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
// A guest snapshot shares the guest's storage, so it is listed as restore
|
||||
// evidence but never counted as a backup. Mirrors the server posture rule that
|
||||
// "snapshots alone do not prove independent recovery".
|
||||
export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
|
||||
return artifact.sourceKind !== 'snapshot';
|
||||
}
|
||||
|
||||
function parseTimestampMs(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
}
|
||||
|
||||
function archiveFileName(volid: string | undefined): string {
|
||||
const trimmed = volid?.trim() ?? '';
|
||||
if (!trimmed) return '';
|
||||
return trimmed.slice(trimmed.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
function pbsBackupFileDetailLabel(fileCount: number): string {
|
||||
if (fileCount <= 0) return 'PBS files not listed';
|
||||
if (fileCount === 1) return '1 PBS file';
|
||||
@@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
size: archive.size,
|
||||
location: archive.storage || archive.node || '—',
|
||||
...artifactLocationScope('archive', archive.instance, archive.storage || archive.node),
|
||||
detail: archive.volid || archive.format || archiveSource.detailFallbackLabel,
|
||||
// The volid repeats the storage, guest type, VMID, and timestamp the row
|
||||
// already shows in their own columns and clips at every width; the
|
||||
// archive format is the one fact it adds, so that is the cell and the
|
||||
// volid stays on hover and in search.
|
||||
detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel,
|
||||
detailTitle: archive.volid || undefined,
|
||||
protected: archive.protected,
|
||||
verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined,
|
||||
running: archive.inProgress === true,
|
||||
@@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
// the "latest" pointers (which answer "what can I recover to?") skip them.
|
||||
// The artifacts themselves stay listed with an honest state.
|
||||
const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||
row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs);
|
||||
row.latestPBS = newest(
|
||||
completed.filter((artifact) => artifact.sourceKind === 'pbs'),
|
||||
(artifact) => artifact.createdMs,
|
||||
@@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
completed.filter((artifact) => artifact.sourceKind === 'snapshot'),
|
||||
(artifact) => artifact.createdMs,
|
||||
);
|
||||
row.latestRecovery = newest(completed, (artifact) => artifact.createdMs);
|
||||
}
|
||||
|
||||
const coverageRows = Array.from(rows.values()).map((row) => {
|
||||
@@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
|
||||
coverageRows.sort((left, right) => {
|
||||
if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank;
|
||||
return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0);
|
||||
return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0);
|
||||
});
|
||||
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
|
||||
|
||||
@@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch(
|
||||
artifact.sourceTitle,
|
||||
artifact.location,
|
||||
artifact.detail,
|
||||
artifact.detailTitle,
|
||||
artifact.verified === true
|
||||
? 'verified'
|
||||
: artifact.verified === false
|
||||
|
||||
@@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record<
|
||||
> = {
|
||||
// Reachability and datastore exhaustion are the two risks that can stop all
|
||||
// future backups. Host telemetry and space-efficiency context return only as
|
||||
// the table gains enough room to keep those headline answers readable.
|
||||
compact: ['server', 'status', 'datastore', 'used', 'backups'],
|
||||
// the table gains enough room to keep those headline answers readable. The
|
||||
// phone projection keeps those two answers whole ("Healthy", "33.6%") and
|
||||
// lets the backup count return with the first wider layout.
|
||||
compact: ['server', 'status', 'datastore', 'used'],
|
||||
basic: ['server', 'status', 'datastore', 'used', 'backups'],
|
||||
operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'],
|
||||
expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'],
|
||||
@@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record<
|
||||
ProxmoxBackupsTableLayoutMode,
|
||||
Partial<Record<BackupServerColumnId, number>>
|
||||
> = {
|
||||
compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 },
|
||||
basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 },
|
||||
compact: { server: 40, status: 23, datastore: 22, used: 15 },
|
||||
// Just above the phone projection the used cell carries its used/total
|
||||
// pair again, the longest value in the row, so it takes the most room.
|
||||
basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 },
|
||||
operational: {
|
||||
server: 20,
|
||||
status: 12,
|
||||
@@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
||||
{ id: 'targetId', label: 'Target ID', kind: 'text' },
|
||||
{ id: 'node', label: 'Node', kind: 'text' },
|
||||
{ id: 'posture', label: 'Posture', kind: 'text' },
|
||||
{ id: 'latest', label: 'Restore', kind: 'numeric-value' },
|
||||
{ id: 'latest', label: 'Last backup', kind: 'numeric-value' },
|
||||
{ id: 'pbs', label: 'PBS snapshot', kind: 'text' },
|
||||
{ id: 'archive', label: 'PVE file', kind: 'text' },
|
||||
{ id: 'snapshot', label: 'Guest snapshot', kind: 'text' },
|
||||
@@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn<CoverageColumnId>[] =
|
||||
|
||||
const COVERAGE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly CoverageColumnId[]> = {
|
||||
// On narrow surfaces answer: which workload, what posture, how recent is the
|
||||
// newest restore point, and did the latest task succeed? Provider-by-provider
|
||||
// newest independent backup, and did the latest task succeed? Provider-by-provider
|
||||
// evidence is progressive detail in the expansion row; target identity folds
|
||||
// beneath the name so the scan stays legible at 320px.
|
||||
compact: ['workload', 'posture', 'latest', 'pbs', 'task'],
|
||||
@@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record<
|
||||
ProxmoxBackupsTableLayoutMode,
|
||||
Partial<Record<CoverageColumnId, number>>
|
||||
> = {
|
||||
compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 },
|
||||
// Posture keeps its whole compact word ("Unknown"), the two age cells hold
|
||||
// the suffix-free compact age, and the job dot takes what a "Job" header
|
||||
// needs.
|
||||
compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 },
|
||||
// Above the phone projection the identity columns (workload, node) take the
|
||||
// slack that the short type badge, target id, and age cells cannot use, so
|
||||
// names stay whole instead of truncating beside empty space.
|
||||
basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 },
|
||||
basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 },
|
||||
operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 },
|
||||
expanded: {
|
||||
workload: 19,
|
||||
@@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record<ProxmoxBackupsTableLayoutMode, readonly Recove
|
||||
// A recovery-point feed starts with identity, source, location, age, and
|
||||
// verification. Size and verbose details progressively return with usable
|
||||
// room; the full values remain available through their title affordance.
|
||||
compact: ['workload', 'source', 'location', 'created', 'state'],
|
||||
// The phone projection keeps the source and state badges and the age whole;
|
||||
// the repository location returns with the first wider layout and stays in
|
||||
// the row's hover title meanwhile.
|
||||
compact: ['workload', 'source', 'created', 'state'],
|
||||
basic: ['workload', 'source', 'location', 'created', 'state'],
|
||||
operational: ['workload', 'type', 'source', 'location', 'created', 'size', 'state'],
|
||||
expanded: ['workload', 'type', 'targetId', 'source', 'location', 'created', 'size', 'state'],
|
||||
@@ -301,7 +311,7 @@ const RECOVERABLE_WEIGHTS: Record<
|
||||
// Reserve enough phone-width space for complete state badges (including
|
||||
// Failed and Running) rather than clipping the recovery answer at the
|
||||
// scroll edge. Identity remains on the canonical 40% anchor.
|
||||
compact: { workload: 40, source: 12, location: 17, created: 15, state: 16 },
|
||||
compact: { workload: 40, source: 16, created: 14, state: 30 },
|
||||
basic: { workload: 28, source: 14, location: 23, created: 18, state: 17 },
|
||||
operational: {
|
||||
workload: 24,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Accessor } from 'solid-js';
|
||||
import { Show, type Accessor } from 'solid-js';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import { type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
@@ -6,7 +6,10 @@ import { ProgressBar } from '@/components/shared/ProgressBar';
|
||||
import { TableHead } from '@/components/shared/Table';
|
||||
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
|
||||
import { WorkloadTypeBadge as SharedWorkloadTypeBadge } from '@/components/shared/WorkloadTypeBadge';
|
||||
import { PlatformTableRelativeTimeValue } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
PlatformTableRelativeTimeValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
import {
|
||||
getRecoveryAgeBand,
|
||||
@@ -219,7 +222,15 @@ export function RowMetricBar(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
|
||||
// Phone columns are too narrow for "18h ago", so the compact form drops the
|
||||
// suffix ("18h", "now") and keeps the full age band and timestamp on hover.
|
||||
export function formatCompactBackupAge(createdAt: string): string {
|
||||
const relative = formatPlatformTableRelativeTimeValue(createdAt);
|
||||
if (relative === 'just now') return 'now';
|
||||
return relative.replace(/ ago$/, '');
|
||||
}
|
||||
|
||||
export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact; compact?: boolean }) {
|
||||
const band = () => getRecoveryAgeBand(props.artifact.createdMs);
|
||||
const title = () => {
|
||||
const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean);
|
||||
@@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) {
|
||||
|
||||
return (
|
||||
<span class={`font-semibold tabular-nums ${recoveryAgeClassByBand[band()]}`} title={title()}>
|
||||
<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />
|
||||
<Show
|
||||
when={props.compact}
|
||||
fallback={<PlatformTableRelativeTimeValue value={props.artifact.createdAt} />}
|
||||
>
|
||||
{formatCompactBackupAge(props.artifact.createdAt)}
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
cpu: 'narrow',
|
||||
memory: 'narrow',
|
||||
disk: 'narrow',
|
||||
temp: 'phone',
|
||||
temp: 'mobile',
|
||||
uptime: 'narrow',
|
||||
cluster: 'tablet',
|
||||
vms: 'compact',
|
||||
@@ -46,8 +46,8 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
|
||||
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
|
||||
// pill) take only what they need.
|
||||
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||
node: 18,
|
||||
version: 7,
|
||||
node: 20,
|
||||
version: 6,
|
||||
uptime: 7,
|
||||
cpu: 13,
|
||||
memory: 13,
|
||||
@@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
|
||||
temp: 6,
|
||||
vms: 5,
|
||||
cts: 5,
|
||||
cluster: 13,
|
||||
cluster: 12,
|
||||
};
|
||||
|
||||
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
@@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
|
||||
disk: 15,
|
||||
uptime: 15,
|
||||
},
|
||||
// Phones carry the same five columns as the guest table below them. A sixth
|
||||
// (temperature) left the node name with four characters, which made a
|
||||
// cluster of "Analytics A".."F" rows unreadable.
|
||||
phone: {
|
||||
node: 32,
|
||||
cpu: 13,
|
||||
memory: 13,
|
||||
disk: 13,
|
||||
temp: 15,
|
||||
uptime: 14,
|
||||
cpu: 15,
|
||||
memory: 15,
|
||||
disk: 15,
|
||||
uptime: 15,
|
||||
},
|
||||
mobile: {
|
||||
node: 32,
|
||||
@@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = (
|
||||
);
|
||||
};
|
||||
|
||||
// The node cell carries the expansion toggle and status dot before the name,
|
||||
// so it needs more than the shared 30% phone identity anchor to show a
|
||||
// typical node name whole.
|
||||
const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38;
|
||||
|
||||
export const getProxmoxHostColumnWidthStyle = (
|
||||
columnId: ProxmoxHostTableColumnId,
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
@@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = (
|
||||
visibleColumnIds,
|
||||
layoutMode === 'narrow'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'phone' || layoutMode === 'mobile'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
: layoutMode === 'phone'
|
||||
? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: layoutMode === 'mobile'
|
||||
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DISK_DETAIL_LIVE_CHARTS,
|
||||
getDiskAttributeValueTextClass,
|
||||
getDiskDetailAttributeCards,
|
||||
getDiskDetailHealthPresentation,
|
||||
getDiskDetailHistoryCharts,
|
||||
getDiskDetailHistoryFallbackMessage,
|
||||
getDiskDetailLiveBadgeLabel,
|
||||
@@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the row verdict and its reason into the drawer header', () => {
|
||||
const disk = {
|
||||
node: 'tower',
|
||||
instance: 'cluster-main',
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'SERIAL-1',
|
||||
wwn: '',
|
||||
size: 1,
|
||||
health: 'PASSED',
|
||||
wearout: -1,
|
||||
type: 'hdd',
|
||||
temperature: 42,
|
||||
rpm: 7200,
|
||||
used: '',
|
||||
riskReasons: [] as string[],
|
||||
};
|
||||
expect(getDiskDetailHealthPresentation(disk)).toEqual({
|
||||
label: 'Healthy',
|
||||
summary: '',
|
||||
tone: 'text-emerald-700 dark:text-emerald-300',
|
||||
});
|
||||
expect(
|
||||
getDiskDetailHealthPresentation({
|
||||
...disk,
|
||||
health: 'FAILED',
|
||||
riskReasons: ['SMART failed.'],
|
||||
}),
|
||||
).toEqual({
|
||||
label: 'Replace Now',
|
||||
summary: 'SMART failed.',
|
||||
tone: 'text-red-700 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds canonical SATA and NVMe attribute cards', () => {
|
||||
expect(
|
||||
getDiskDetailAttributeCards({
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
PHYSICAL_DISK_TABLE_CLASS,
|
||||
PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS,
|
||||
getPhysicalDiskEmptyStatePresentation,
|
||||
getPhysicalDiskColumnWidthPercent,
|
||||
getPhysicalDiskCellPaddingClass,
|
||||
getPhysicalDiskColumnWidthStyle,
|
||||
getPhysicalDiskCollectionMessages,
|
||||
getPhysicalDiskFieldStatusMessage,
|
||||
getPhysicalDiskHealthCompactLabel,
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
getPhysicalDiskHostLabel,
|
||||
@@ -158,14 +160,55 @@ describe('diskPresentation', () => {
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14);
|
||||
expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9);
|
||||
expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0);
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' });
|
||||
expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false);
|
||||
expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true);
|
||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40);
|
||||
expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15);
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' });
|
||||
expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' });
|
||||
});
|
||||
|
||||
it('keeps the desktop disk column widths on the canonical weighted helper', () => {
|
||||
const columns = [
|
||||
'disk',
|
||||
'device',
|
||||
'host',
|
||||
'role',
|
||||
'parent',
|
||||
'health',
|
||||
'life',
|
||||
'temp',
|
||||
'size',
|
||||
] as const;
|
||||
expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([
|
||||
'19%',
|
||||
'9%',
|
||||
'10%',
|
||||
'8%',
|
||||
'12%',
|
||||
'17%',
|
||||
'6%',
|
||||
'7%',
|
||||
'12%',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sheds the shared cell padding only on the phone disk layouts', () => {
|
||||
expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('!px-1');
|
||||
expect(getPhysicalDiskCellPaddingClass('compact')).toBe('!px-1');
|
||||
expect(getPhysicalDiskCellPaddingClass('basic')).toBe('');
|
||||
expect(getPhysicalDiskCellPaddingClass('full')).toBe('');
|
||||
});
|
||||
|
||||
it('shortens only the health words that cannot fit a phone health column', () => {
|
||||
expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy');
|
||||
expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('detects SMART warnings from counters', () => {
|
||||
|
||||
+2
-2
@@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { zfsPool: { state: 'FAULTED', devices: [] } },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => {
|
||||
|
||||
+2
-2
@@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
it('titleizes a non-ONLINE zfs pool state like every other state path', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
@@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('titleizes the derived status when arrayState and pool are both absent', () => {
|
||||
|
||||
@@ -101,6 +101,47 @@ describe('storage row presentation', () => {
|
||||
expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity');
|
||||
});
|
||||
|
||||
it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => {
|
||||
const resilvering = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'ZFS pool tank is resilvering',
|
||||
details: {
|
||||
zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] },
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering');
|
||||
expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering');
|
||||
|
||||
const scrubbing = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'ZFS pool tank scrub is running (45.2%)',
|
||||
protectionSummary: 'ZFS pool tank scrub is running (45.2%)',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
scan: '',
|
||||
scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 },
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%');
|
||||
expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe(
|
||||
'ZFS pool tank scrub is running (45.2%)',
|
||||
);
|
||||
|
||||
// A rebuild without zfs scan data keeps the label its platform supplied.
|
||||
const parity = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'Parity sync 12%',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%');
|
||||
expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%');
|
||||
});
|
||||
|
||||
it('derives zfs issue fallback from pool state and errors', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
STORAGE_BAR_ZFS_SECTION_CLASS,
|
||||
STORAGE_BAR_ZFS_STATE_LABEL_CLASS,
|
||||
STORAGE_BAR_ZFS_STATE_ROW_CLASS,
|
||||
getStorageBarCompactLabel,
|
||||
getStorageBarLabel,
|
||||
getStorageBarTooltipRowClass,
|
||||
getStorageBarTooltipRows,
|
||||
@@ -39,6 +40,7 @@ describe('storageBarPresentation', () => {
|
||||
expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400');
|
||||
expect(getStorageBarUsagePercent(40, 100)).toBe(40);
|
||||
expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)');
|
||||
expect(getStorageBarCompactLabel(40, 100)).toBe('40%');
|
||||
expect(getStorageBarTooltipTitle()).toBe('Storage Details');
|
||||
expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 ');
|
||||
expect(getStorageBarTooltipRowClass(true)).toBe(
|
||||
|
||||
+32
-9
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStoragePoolColumnWidthPercent,
|
||||
getStoragePoolCellPaddingClass,
|
||||
getStoragePoolColumnWidthStyle,
|
||||
getStoragePoolTableColumns,
|
||||
getStoragePoolTableLayoutModeForContainer,
|
||||
getStorageTableHeading,
|
||||
@@ -46,22 +47,44 @@ describe('storagePagePresentation', () => {
|
||||
expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational');
|
||||
// The compact/operational boundary is the shared phone container query
|
||||
// (34rem), so the usage bar label and the column set change together.
|
||||
expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational');
|
||||
expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full');
|
||||
expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true);
|
||||
expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21);
|
||||
expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' });
|
||||
expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' });
|
||||
expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true);
|
||||
expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false);
|
||||
expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25);
|
||||
expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0);
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' });
|
||||
expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' });
|
||||
expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false);
|
||||
expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false);
|
||||
expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40);
|
||||
expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20);
|
||||
expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' });
|
||||
expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' });
|
||||
});
|
||||
|
||||
it('sheds the desktop cell gutter only on the phone pool layouts', () => {
|
||||
expect(getStoragePoolCellPaddingClass('narrow')).toBe('!px-1');
|
||||
expect(getStoragePoolCellPaddingClass('compact')).toBe('!px-1');
|
||||
expect(getStoragePoolCellPaddingClass('operational')).toBe('');
|
||||
expect(getStoragePoolCellPaddingClass('full')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the desktop pool column widths on the canonical weighted helper', () => {
|
||||
const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map(
|
||||
(column) =>
|
||||
getStoragePoolColumnWidthStyle(
|
||||
'full',
|
||||
column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth',
|
||||
).width,
|
||||
);
|
||||
expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
type PhysicalDiskPresentationData,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { formatPowerOnHours } from '@/utils/format';
|
||||
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
|
||||
@@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time';
|
||||
export const getDiskDetailHistoryFallbackMessage = (): string =>
|
||||
'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.';
|
||||
|
||||
export type DiskDetailHealthPresentation = {
|
||||
label: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
};
|
||||
|
||||
// The drawer header carries the disk's verdict and its reason. The table row
|
||||
// shows only the verdict word, so this is where the reason is read in full.
|
||||
export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1';
|
||||
export const DISK_DETAIL_HEALTH_ROW_CLASS =
|
||||
'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]';
|
||||
export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold';
|
||||
export const DISK_DETAIL_HEALTH_SUMMARY_CLASS =
|
||||
'min-w-0 max-w-full whitespace-normal break-words text-muted';
|
||||
|
||||
export function getDiskDetailHealthPresentation(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
): DiskDetailHealthPresentation {
|
||||
const status = getPhysicalDiskHealthStatus(disk);
|
||||
return {
|
||||
label: status.label,
|
||||
summary: getPhysicalDiskHealthSummary(status),
|
||||
tone: status.tone,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiskDetailAttributeCards(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
diskTempThresholds?: MetricDisplayThresholds | null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type {
|
||||
PhysicalDiskCollectionStatus,
|
||||
PhysicalDiskFieldStatus,
|
||||
Resource,
|
||||
} from '@/types/resource';
|
||||
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
getSourcePlatformLabel,
|
||||
getSourcePlatformPresentation,
|
||||
@@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record<
|
||||
full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'],
|
||||
};
|
||||
|
||||
const PHYSICAL_DISK_COLUMN_WIDTHS: Record<
|
||||
// Relative weights per layout, resolved through the canonical weighted-width
|
||||
// helper. The phone layouts are sized from the measured values they show:
|
||||
// a temperature ("100°C"), a capacity ("5.46 TB"), a compact health word and
|
||||
// a short node name must each fit whole, and the disk model takes the rest.
|
||||
const PHYSICAL_DISK_COLUMN_WEIGHTS: Record<
|
||||
PhysicalDiskTableLayoutMode,
|
||||
Partial<Record<PhysicalDiskTableColumnId, number>>
|
||||
> = {
|
||||
narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 },
|
||||
compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 },
|
||||
narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 },
|
||||
compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 },
|
||||
basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 },
|
||||
operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 },
|
||||
expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 },
|
||||
@@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = (
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId);
|
||||
|
||||
export const getPhysicalDiskColumnWidthPercent = (
|
||||
export const getPhysicalDiskColumnWidthStyle = (
|
||||
layout: PhysicalDiskTableLayoutMode,
|
||||
columnId: PhysicalDiskTableColumnId,
|
||||
): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0;
|
||||
): JSX.CSSProperties =>
|
||||
getPlatformTableWeightedColumnWidthStyle(
|
||||
columnId,
|
||||
PHYSICAL_DISK_COLUMN_WEIGHTS[layout],
|
||||
PHYSICAL_DISK_VISIBLE_COLUMNS[layout],
|
||||
);
|
||||
|
||||
// The shared table cell primitives pad every cell for desktop density. On the
|
||||
// phone layouts that padding alone is a fifth of the row, so rendered cells
|
||||
// and headers shed it the same way the Proxmox replication table does.
|
||||
export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = '!px-1';
|
||||
|
||||
export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string =>
|
||||
layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : '';
|
||||
|
||||
// Health words that do not fit a phone-width health column fall back to a
|
||||
// shorter form; the full label stays in the wider projections and the detail.
|
||||
export const getPhysicalDiskHealthCompactLabel = (label: string): string => {
|
||||
if (label === 'Needs Attention') return 'Attention';
|
||||
if (label === 'Replace Now') return 'Replace';
|
||||
return label;
|
||||
};
|
||||
|
||||
export const PHYSICAL_DISK_COL_DISK_CLASS = '';
|
||||
export const PHYSICAL_DISK_COL_DEVICE_CLASS = '';
|
||||
@@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted';
|
||||
export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium';
|
||||
export const PHYSICAL_DISK_HEALTH_WRAP_CLASS =
|
||||
'flex min-w-0 items-center gap-1.5 whitespace-nowrap';
|
||||
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold';
|
||||
export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted';
|
||||
// The health word is the row's verdict and always renders whole. Its reason
|
||||
// lives in the cell title and in the disk drawer, not in a truncating sibling
|
||||
// that would compete with it for the track.
|
||||
export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold';
|
||||
export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium';
|
||||
export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content';
|
||||
|
||||
|
||||
@@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string {
|
||||
return 'text-base-content';
|
||||
}
|
||||
|
||||
// The rebuild summary the backend promotes into the protection label is a
|
||||
// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is
|
||||
// a badge, so a ZFS scan reads as the activity word, derived from the pool's
|
||||
// own scan state (the same source the backend keys its risk reason on); the
|
||||
// sentence stays available as the cell title. Non-ZFS rebuilds keep the label
|
||||
// their platform supplies.
|
||||
export function getStoragePoolRebuildLabel(record: StorageRecord): string {
|
||||
const pool = getStorageRecordZfsPool(record);
|
||||
if (!pool) return '';
|
||||
const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase();
|
||||
const percentage = pool.scanDetails?.percentage;
|
||||
const progress =
|
||||
typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0
|
||||
? ` ${Math.round(percentage)}%`
|
||||
: '';
|
||||
if (scan.includes('resilver')) return `Resilvering${progress}`;
|
||||
if (scan.includes('scrub')) return `Scrubbing${progress}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string {
|
||||
const label = (record.protectionLabel || '').trim();
|
||||
if (record.rebuildInProgress || record.protectionReduced) {
|
||||
if (record.rebuildInProgress) {
|
||||
return getStoragePoolRebuildLabel(record) || label || '—';
|
||||
}
|
||||
if (record.protectionReduced) {
|
||||
return label || '—';
|
||||
}
|
||||
if (label && label.toLowerCase() !== 'healthy') {
|
||||
@@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string {
|
||||
}
|
||||
const pool = getStorageRecordZfsPool(record);
|
||||
if (pool?.state) {
|
||||
return pool.state === 'ONLINE' ? 'Online' : pool.state;
|
||||
// zpool reports upper-case states; present them like every other state
|
||||
// path so a DEGRADED pool reads the same as a degraded array.
|
||||
return titleize(pool.state);
|
||||
}
|
||||
const status = getStorageRecordStatus(record);
|
||||
return status ? titleize(status) : '—';
|
||||
@@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str
|
||||
return issueSummary;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
// When the badge shows a derived word, the platform's full label is the title.
|
||||
const fullLabel = (record.protectionLabel || '').trim();
|
||||
return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label;
|
||||
}
|
||||
|
||||
export function getCompactStoragePoolImpactLabel(record: StorageRecord): string {
|
||||
|
||||
@@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number =
|
||||
return (used / total) * 100;
|
||||
};
|
||||
|
||||
// Phone-width tables only have room for the percentage inside the bar; the
|
||||
// used/total sublabel stays in the tooltip and in the wider projections.
|
||||
export const getStorageBarCompactLabel = (used: number, total: number): string =>
|
||||
formatPercent(getStorageBarUsagePercent(used, total));
|
||||
|
||||
export const getStorageBarLabel = (used: number, total: number): string =>
|
||||
`${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`;
|
||||
`${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`;
|
||||
|
||||
export const getStorageBarTooltipTitle = (): string => 'Storage Details';
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { StorageSortKey } from './storageModelCore';
|
||||
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
|
||||
import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
export type StorageViewOption = {
|
||||
value: 'pools' | 'disks';
|
||||
@@ -21,11 +23,14 @@ export type StoragePoolTableColumn = {
|
||||
|
||||
export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full';
|
||||
|
||||
// The compact/operational boundary matches the shared phone projection of the
|
||||
// table container (`@container (max-width: 33.999rem)` = 544px), so the
|
||||
// columns and the in-cell labels always change projection together.
|
||||
export const getStoragePoolTableLayoutModeForContainer = (
|
||||
containerWidth: number,
|
||||
): StoragePoolTableLayoutMode => {
|
||||
if (containerWidth >= 1_040) return 'full';
|
||||
if (containerWidth >= 560) return 'operational';
|
||||
if (containerWidth >= 544) return 'operational';
|
||||
if (containerWidth > 0 && containerWidth < 360) return 'narrow';
|
||||
return 'compact';
|
||||
};
|
||||
@@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record<
|
||||
full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'],
|
||||
};
|
||||
|
||||
const STORAGE_POOL_COLUMN_WIDTHS: Record<
|
||||
// Relative weights per layout, resolved through the canonical weighted-width
|
||||
// helper. On phones the usage bar carries only its percentage, so most of the
|
||||
// row goes to the two identity strings (pool name and host); a state word such
|
||||
// as "Degraded" must still fit whole. The shared 30% identity anchor is not
|
||||
// used here because a four-column row cannot keep the host readable with it.
|
||||
const STORAGE_POOL_COLUMN_WEIGHTS: Record<
|
||||
StoragePoolTableLayoutMode,
|
||||
Partial<Record<StoragePoolTableColumnId, number>>
|
||||
> = {
|
||||
narrow: { name: 40, state: 20, host: 20, usage: 20 },
|
||||
compact: { name: 35, state: 20, host: 20, usage: 25 },
|
||||
operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 },
|
||||
narrow: { name: 35, state: 21, host: 31, usage: 13 },
|
||||
compact: { name: 37, state: 18.5, host: 31.5, usage: 13 },
|
||||
// The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage
|
||||
// track must hold ~120px of text from the first operational width.
|
||||
operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 },
|
||||
full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 },
|
||||
};
|
||||
|
||||
@@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = (
|
||||
columnId: StoragePoolTableColumnId,
|
||||
): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId);
|
||||
|
||||
export const getStoragePoolColumnWidthPercent = (
|
||||
// On phones the pool row sheds the desktop cell gutter the same way its
|
||||
// headers (via the shared container query) and the physical-disk rows do:
|
||||
// four cells at 12px each were 48px of a 361px row, and the measured values
|
||||
// fit the tracks with only a pixel or two to spare.
|
||||
export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = '!px-1';
|
||||
|
||||
export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string =>
|
||||
layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : '';
|
||||
|
||||
export const getStoragePoolColumnWidthStyle = (
|
||||
layout: StoragePoolTableLayoutMode,
|
||||
columnId: StoragePoolTableColumnId,
|
||||
): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0;
|
||||
): JSX.CSSProperties =>
|
||||
getPlatformTableWeightedColumnWidthStyle(
|
||||
columnId,
|
||||
STORAGE_POOL_COLUMN_WEIGHTS[layout],
|
||||
STORAGE_POOL_VISIBLE_COLUMNS[layout],
|
||||
);
|
||||
|
||||
const STORAGE_POOL_TABLE_HEADER_CLASS =
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider';
|
||||
|
||||
Reference in New Issue
Block a user