mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Preserve missing capacity readings in storage pool details
Do not turn absent used capacity into an empty pool or invented free space. Preserve independently observed bytes and explicit percentages, with guarded derivation only from known inputs. Cover live drawer transitions and desktop/phone browser states. Contract-Neutral: Correct presentation of existing nullable capacity fields; no shared primitive API, layout, collector schema or recovery authority changes. Change-source: pulse-maintainer
This commit is contained in:
parent
811961243e
commit
60a6c933e0
8 changed files with 340 additions and 30 deletions
|
|
@ -21,6 +21,24 @@
|
|||
|
||||
## Purpose
|
||||
|
||||
### Pool detail capacity observations
|
||||
|
||||
The existing pool drawer's Configuration rows preserve missing capacity as
|
||||
`n/a`, independently for Used, Free, Total and Usage. Finite non-negative
|
||||
observations, including measured zero, remain visible even when another field
|
||||
is absent. Free space may be derived only when both total and used bytes are
|
||||
observed; usage may be derived only when used is observed and total is positive.
|
||||
Explicit provider free-space and percentage observations take precedence over
|
||||
those derived values. Unknown capacity is not an empty pool or proof of free
|
||||
space. This presentation does not establish freshness, pool health or recovery.
|
||||
|
||||
Regression verification: the capacity evidence matrix in
|
||||
`frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts`
|
||||
and the production-drawer fixture in
|
||||
`frontend-modern/browser-tests/pool-capacity.cjs` cover missing, empty, full and
|
||||
partial snapshots, including live transitions on desktop and phone viewports.
|
||||
These synthetic checks are not native collector acceptance.
|
||||
|
||||
### RAID required-member count evidence — issue #2369
|
||||
|
||||
Canonical host RAID metadata carries optional `requiredDevices`, excluding
|
||||
|
|
|
|||
113
frontend-modern/browser-tests/pool-capacity.cjs
Normal file
113
frontend-modern/browser-tests/pool-capacity.cjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const artifacts = path.join(root, 'node_modules/pool-capacity-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
|
||||
server: { host: '127.0.0.1', port: 5222, strictPort: true },
|
||||
});
|
||||
const observations = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const scenario of [
|
||||
{ engine: chromium, name: 'chromium', width: 1365, height: 900 },
|
||||
{ engine: webkit, name: 'webkit', width: 390, height: 844 },
|
||||
]) {
|
||||
browser = await scenario.engine.launch(
|
||||
scenario.name === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: scenario.width, height: scenario.height },
|
||||
isMobile: scenario.name === 'webkit',
|
||||
hasTouch: scenario.name === 'webkit',
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5222') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.pathname === '/api/license/runtime-capabilities'
|
||||
? {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
}
|
||||
: { data: [], enabled: false },
|
||||
});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5222/browser-tests/pool-capacity.html');
|
||||
if (scenario.name === 'webkit')
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
for (const [phase, expected] of Object.entries({
|
||||
missing: ['n/a', 'n/a', '1.00 KB', 'n/a'],
|
||||
empty: ['0 B', '1.00 KB', '1.00 KB', '0%'],
|
||||
full: ['1.00 KB', '0 B', '1.00 KB', '100%'],
|
||||
partial: ['512 B', '0 B', 'n/a', 'n/a'],
|
||||
absent: ['n/a', 'n/a', 'n/a', 'n/a'],
|
||||
})) {
|
||||
await page.getByRole('button', { name: phase, exact: true }).click();
|
||||
const actual = [];
|
||||
for (const label of ['Used', 'Free', 'Total', 'Usage'])
|
||||
actual.push(
|
||||
await page
|
||||
.getByText(label, { exact: true })
|
||||
.locator('..')
|
||||
.locator('span')
|
||||
.last()
|
||||
.textContent(),
|
||||
);
|
||||
const screenshot = path.join(artifacts, `${scenario.name}-${phase}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
assert.deepEqual(actual, expected, `${scenario.name} ${phase}`);
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
width: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
observations.push({
|
||||
browser: scenario.name,
|
||||
version: browser.version(),
|
||||
phase,
|
||||
actual,
|
||||
dimensions,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify(
|
||||
{ playwright: require('playwright/package.json').version, observations },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
12
frontend-modern/browser-tests/pool-capacity.html
Normal file
12
frontend-modern/browser-tests/pool-capacity.html
Normal file
|
|
@ -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>Storage capacity absence verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/pool-capacity.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
frontend-modern/browser-tests/pool-capacity.tsx
Normal file
56
frontend-modern/browser-tests/pool-capacity.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Production pool drawer with synthetic partial collector snapshots; not appliance proof.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { StoragePoolDetail } from '../src/components/Storage/StoragePoolDetail';
|
||||
import type { CapacitySnapshot, StorageRecord } from '../src/features/storageBackups/models';
|
||||
import '../src/index.css';
|
||||
|
||||
const samples: Record<string, CapacitySnapshot> = {
|
||||
missing: { totalBytes: 1024, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
empty: { totalBytes: 1024, usedBytes: 0, freeBytes: null, usagePercent: null },
|
||||
full: { totalBytes: 1024, usedBytes: 1024, freeBytes: 0, usagePercent: 100 },
|
||||
partial: { totalBytes: null, usedBytes: 512, freeBytes: 0, usagePercent: null },
|
||||
absent: { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
};
|
||||
const Fixture = () => {
|
||||
const [phase, setPhase] = createSignal('missing');
|
||||
const record = (): StorageRecord => ({
|
||||
id: 'synthetic-pbs-pool',
|
||||
name: 'Archive',
|
||||
category: 'datastore',
|
||||
health: 'unknown',
|
||||
location: { label: 'archive-host', scope: 'host' },
|
||||
source: {
|
||||
platform: 'proxmox-pbs',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
capacity: samples[phase()],
|
||||
capabilities: ['capacity'],
|
||||
observedAt: Date.now(),
|
||||
});
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1 class="text-lg font-semibold">Storage capacity absence verification</h1>
|
||||
<p>Synthetic partial PBS snapshots in the production pool drawer.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{Object.keys(samples).map((name) => (
|
||||
<button class="min-h-11 rounded border border-border px-3" onClick={() => setPhase(name)}>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={record()}
|
||||
physicalDisks={[]}
|
||||
summarySeriesId="synthetic-pbs-pool"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "33d6d729c1dc7c0913a970fd4c463a9f9575b7b1",
|
||||
"verified_at": "2026-10-02T10:16:57.632722Z",
|
||||
"base_sha": "811961243e6418c858c80fd073faf4251fe838d3",
|
||||
"verified_at": "2026-10-02T12:57:15.855691+00:00",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx"
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx": "df63db162779ff0d2064475a9db1fd67ac748703f398a88d108ecdd154dae9d2"
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "59ca48c06cb3c63f30ccb46cb30536cbc6d86fda1b50e6ea49ecefb9eb377fae"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/disk-live-refresh.html"
|
||||
"/browser-tests/pool-capacity.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -23,31 +23,26 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"Production DiskList, DiskDetail, keyed renderer and styles with synthetic PBS collector snapshots, not installed appliance evidence",
|
||||
"Healthy to failed disk updates health, warning text, endurance, temperature, model and canonical target without remounting the row or its expanded detail",
|
||||
"Missing measurements remove obsolete values and critical styling; unavailable detail evidence remains visible; recovery restores current values",
|
||||
"Disclosure keyboard focus and aria-controls survive same-ID updates; changed metric targets update summary/detail linkage",
|
||||
"Attention filter includes the failed disk, removes the recovered disk and returns current healthy values in All",
|
||||
"Desktop Chromium light and phone-emulated WebKit dark have no outer-page overflow or page errors; six fault/missing/recovered screenshots inspected; existing dense phone truncation is unchanged"
|
||||
"Production pool drawer with synthetic partial PBS snapshots, not native collector or installed acceptance",
|
||||
"Missing used/free/usage remain n/a; measured empty and full pools preserve 0 B and 0%",
|
||||
"Independent byte observations remain visible without total; absent capacity stays n/a",
|
||||
"Live transitions across five states on desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"Expand the real disk row and focus its disclosure, apply fault/missing/recovery snapshots without navigating or reloading",
|
||||
"Check current values, stable owner, focus, live disclosure target and selected detail after each snapshot",
|
||||
"Toggle Attention/All across fault and recovery"
|
||||
"Switch missing, empty, full, partial and absent snapshots without reload",
|
||||
"Read Used, Free, Total and Usage values in the actual Configuration rows"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/disk-live-refresh.cjs",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pool-capacity.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-fault.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-missing.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-recovered.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-fault.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-missing.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-recovered.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/result.json"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-partial.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-missing.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-full.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-empty.png"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StoragePoolDetail } from '@/components/Storage/StoragePoolDetail';
|
||||
|
|
@ -41,6 +42,46 @@ const makeRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
|||
});
|
||||
|
||||
describe('StoragePoolDetail', () => {
|
||||
it('updates capacity rows without presenting missing readings as an empty pool', () => {
|
||||
const [record, setRecord] = createSignal(makeRecord());
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail record={record()} physicalDisks={[]} summarySeriesId="pool:tank" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
const rowValue = (label: string) =>
|
||||
screen.getByText(label, { exact: true }).parentElement?.lastElementChild?.textContent;
|
||||
expect(rowValue('Usage')).toBe('40%');
|
||||
setRecord(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: 1000, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('n/a');
|
||||
expect(rowValue('Free')).toBe('n/a');
|
||||
expect(rowValue('Total')).toBe('1000 B');
|
||||
expect(rowValue('Usage')).toBe('n/a');
|
||||
setRecord(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: 1000, usedBytes: 0, freeBytes: null, usagePercent: null },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('0 B');
|
||||
expect(rowValue('Free')).toBe('1000 B');
|
||||
expect(rowValue('Usage')).toBe('0%');
|
||||
setRecord(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: null, usedBytes: 500, freeBytes: 0, usagePercent: null },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('500 B');
|
||||
expect(rowValue('Free')).toBe('0 B');
|
||||
expect(rowValue('Total')).toBe('n/a');
|
||||
expect(rowValue('Usage')).toBe('n/a');
|
||||
});
|
||||
|
||||
it('uses canonical metrics target for capacity history charts', () => {
|
||||
historyChartSpy.mockClear();
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,71 @@ describe('storagePoolDetailPresentation', () => {
|
|||
expect(getZfsErrorTextClass()).toBe('font-medium text-red-600 dark:text-red-400');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'missing used',
|
||||
{ totalBytes: 1024, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
['n/a', 'n/a', '1.00 KB', 'n/a'],
|
||||
],
|
||||
[
|
||||
'all absent',
|
||||
{ totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
['n/a', 'n/a', 'n/a', 'n/a'],
|
||||
],
|
||||
[
|
||||
'measured empty',
|
||||
{ totalBytes: 1024, usedBytes: 0, freeBytes: null, usagePercent: null },
|
||||
['0 B', '1.00 KB', '1.00 KB', '0%'],
|
||||
],
|
||||
[
|
||||
'measured full',
|
||||
{ totalBytes: 1024, usedBytes: 1024, freeBytes: 0, usagePercent: 100 },
|
||||
['1.00 KB', '0 B', '1.00 KB', '100%'],
|
||||
],
|
||||
[
|
||||
'independent observations',
|
||||
{ totalBytes: null, usedBytes: 512, freeBytes: 0, usagePercent: null },
|
||||
['512 B', '0 B', 'n/a', 'n/a'],
|
||||
],
|
||||
[
|
||||
'explicit ratio',
|
||||
{ totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: 25 },
|
||||
['n/a', 'n/a', 'n/a', '25%'],
|
||||
],
|
||||
[
|
||||
'explicit zero ratio',
|
||||
{ totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: 0 },
|
||||
['n/a', 'n/a', 'n/a', '0%'],
|
||||
],
|
||||
[
|
||||
'zero total',
|
||||
{ totalBytes: 0, usedBytes: 0, freeBytes: 0, usagePercent: null },
|
||||
['0 B', '0 B', '0 B', 'n/a'],
|
||||
],
|
||||
[
|
||||
'invalid observations',
|
||||
{ totalBytes: Infinity, usedBytes: NaN, freeBytes: -1, usagePercent: -1 },
|
||||
['n/a', 'n/a', 'n/a', 'n/a'],
|
||||
],
|
||||
[
|
||||
'derived ratio',
|
||||
{ totalBytes: 1024, usedBytes: 512, freeBytes: null, usagePercent: null },
|
||||
['512 B', '512 B', '1.00 KB', '50%'],
|
||||
],
|
||||
[
|
||||
'provider free differs from subtraction',
|
||||
{ totalBytes: 1024, usedBytes: 512, freeBytes: 256, usagePercent: 60 },
|
||||
['512 B', '256 B', '1.00 KB', '60%'],
|
||||
],
|
||||
] as const)('preserves capacity evidence: %s', (_name, capacity, expected) => {
|
||||
const rows = buildStoragePoolDetailConfigRows(buildRecord({ capacity }));
|
||||
expect(
|
||||
['Used', 'Free', 'Total', 'Usage'].map(
|
||||
(label) => rows.find((row) => row.label === label)?.value,
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('formats zfs error summaries canonically', () => {
|
||||
expect(getZfsErrorSummary(1, 2, 3)).toBe('Errors: R:1 W:2 C:3');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
getStorageRecordShared,
|
||||
getStorageRecordStatus,
|
||||
getStorageRecordType,
|
||||
getStorageRecordUsagePercent,
|
||||
getStorageRecordZfsPool,
|
||||
} from '@/features/storageBackups/recordPresentation';
|
||||
import { resolveStorageRecordMetricResourceId } from '@/features/storageBackups/storageMetricsIdentity';
|
||||
|
|
@ -106,10 +105,21 @@ export function resolveStoragePoolDetailChartTarget(
|
|||
export function buildStoragePoolDetailConfigRows(
|
||||
record: StorageRecord,
|
||||
): StoragePoolDetailConfigRow[] {
|
||||
const totalBytes = record.capacity.totalBytes || 0;
|
||||
const usedBytes = record.capacity.usedBytes || 0;
|
||||
// Missing collector observations are not measurements of an empty pool.
|
||||
const observed = (value: number | null): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
const totalBytes = observed(record.capacity.totalBytes);
|
||||
const usedBytes = observed(record.capacity.usedBytes);
|
||||
const freeBytes =
|
||||
record.capacity.freeBytes ?? (totalBytes > 0 ? Math.max(totalBytes - usedBytes, 0) : 0);
|
||||
observed(record.capacity.freeBytes) ??
|
||||
(totalBytes !== null && usedBytes !== null ? Math.max(totalBytes - usedBytes, 0) : null);
|
||||
const usagePercent =
|
||||
observed(record.capacity.usagePercent) ??
|
||||
(totalBytes !== null && totalBytes > 0 && usedBytes !== null
|
||||
? (usedBytes / totalBytes) * 100
|
||||
: null);
|
||||
const bytesLabel = (value: number | null): string =>
|
||||
value === null ? 'n/a' : formatBytes(value);
|
||||
const content = getStorageRecordContent(record);
|
||||
const rows: StoragePoolDetailConfigRow[] = [
|
||||
{ label: 'Node', value: getStorageRecordNodeLabel(record) },
|
||||
|
|
@ -125,10 +135,10 @@ export function buildStoragePoolDetailConfigRows(
|
|||
? 'Yes'
|
||||
: 'No',
|
||||
},
|
||||
{ label: 'Used', value: totalBytes > 0 ? formatBytes(usedBytes) : 'n/a' },
|
||||
{ label: 'Free', value: totalBytes > 0 ? formatBytes(freeBytes) : 'n/a' },
|
||||
{ label: 'Total', value: totalBytes > 0 ? formatBytes(totalBytes) : 'n/a' },
|
||||
{ label: 'Usage', value: formatPercent(getStorageRecordUsagePercent(record)) },
|
||||
{ label: 'Used', value: bytesLabel(usedBytes) },
|
||||
{ label: 'Free', value: bytesLabel(freeBytes) },
|
||||
{ label: 'Total', value: bytesLabel(totalBytes) },
|
||||
{ label: 'Usage', value: usagePercent === null ? 'n/a' : formatPercent(usagePercent) },
|
||||
];
|
||||
|
||||
if (content) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue