mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge reviewed storage pool disk ownership repair
Accept exact candidate 34f73590bb with retained source and browser proof. Resolve only the shared receipt pointer; runtime content remains exact to its non-merge proof commits.
Change-source: pulse-maintainer
This commit is contained in:
@@ -7752,3 +7752,20 @@ production disk/pool details and API transport in phone Chromium/WebKit, native
|
||||
vertical panning in Chromium, and desktop mouse/keyboard controls. Synthetic
|
||||
sample presentation is not native collector, physical device, assistive-device
|
||||
or installed whole-report acceptance.
|
||||
|
||||
### Shared Storage presenters preserve disk ownership
|
||||
|
||||
Pool-detail presenters under `features/storageBackups/` must not use repeated
|
||||
device paths, UnRAID group labels or host display names as fleet-wide identity.
|
||||
Direct pool children are retained; inferred membership requires the same
|
||||
canonical host parent, or a complete native Proxmox instance/node pair when
|
||||
parents are missing. Known parent conflicts win over native names. Full ZFS
|
||||
device identifiers may normalize an optional `/dev/` prefix but must not match
|
||||
arbitrary suffixes. Missing ownership produces no invented linked-disk facts.
|
||||
The storage adapter preserves the instance needed for that presentation scope;
|
||||
this does not rewrite canonical resources or History targets. Existing keyed
|
||||
pool details must update linked disks and local health through target/snapshot
|
||||
replacement without retaining peer-host rows. The mounted
|
||||
`StoragePoolDetail.ownership.test.tsx` and production-component browser fixture
|
||||
`browser-tests/pool-ownership.cjs` verify desktop/phone presentation and History
|
||||
coexistence; they are not field collection or native recovery evidence.
|
||||
|
||||
@@ -6311,3 +6311,26 @@ units, collection, retention, access or recovery boundary. The production
|
||||
disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted
|
||||
`HistoryChart.test.tsx` regressions verify presentation/input behaviour only,
|
||||
not live appliance collection or recovery success.
|
||||
|
||||
### Pool-to-physical-disk ownership in Storage details
|
||||
|
||||
`storagePoolDetailPresentation.ts` must scope inferred ZFS device and UnRAID
|
||||
array/cache-group membership to the owning host. An explicit disk parent equal
|
||||
to the canonical pool ID or its retained resource reference remains a direct
|
||||
association. Otherwise, matching canonical host parents establishes scope;
|
||||
conflicting parents cannot be overridden by names, paths or group labels.
|
||||
When canonical parents are unavailable, only a complete matching native
|
||||
Proxmox instance/node pair establishes scope. `storageAdapters.ts` retains that
|
||||
instance alongside the native node. Display names and unscoped hostnames alone
|
||||
are insufficient. Unknown ownership must not present another host's SMART,
|
||||
temperature, counters or topology counts as this pool's evidence.
|
||||
|
||||
ZFS device matching compares full identifiers with only the optional `/dev/`
|
||||
prefix normalized, not arbitrary suffixes or invented partition associations.
|
||||
This is read-only presentation: no collector, canonical server identity,
|
||||
metrics target, health threshold, persistence or recovery authority changes.
|
||||
`storagePoolDiskOwnership.test.ts`, the mounted
|
||||
`StoragePoolDetail.ownership.test.tsx` and `browser-tests/pool-ownership.cjs`
|
||||
cover repeated paths/groups, direct children, scoped legacy snapshots, missing
|
||||
ownership and live target replacement. Synthetic presentation proof does not
|
||||
establish native appliance, installation or recovery acceptance.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const parent = false;
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
const artifacts = path.join(root, 'node_modules/pool-ownership-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: 5234, strictPort: true },
|
||||
});
|
||||
const playwright = require('playwright/package.json').version;
|
||||
const expected = JSON.parse(
|
||||
fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'),
|
||||
).packages['node_modules/@playwright/test'].version;
|
||||
assert.equal(playwright, expected);
|
||||
const observations = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, phone] of [
|
||||
['chromium-desktop', chromium, false],
|
||||
['webkit-phone', webkit, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 },
|
||||
hasTouch: phone,
|
||||
isMobile: phone,
|
||||
});
|
||||
const requests = [],
|
||||
errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5234') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
requests.push(Object.fromEntries(url.searchParams));
|
||||
return route.fulfill({
|
||||
json: {
|
||||
points: [{ timestamp: 1790935200000, value: 25, min: 25, max: 25 }],
|
||||
source: 'store',
|
||||
},
|
||||
});
|
||||
}
|
||||
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:5234/browser-tests/pool-ownership.html');
|
||||
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const detail = page.getByRole('region', { name: 'Pool detail' });
|
||||
const phases = parent
|
||||
? [
|
||||
['ZFS host A', 4, null],
|
||||
['UnRAID host A', 3, null],
|
||||
]
|
||||
: [
|
||||
['ZFS host A', 1, 'Host A disk'],
|
||||
['ZFS host B', 1, 'Host B disk'],
|
||||
['Missing ownership', 0, null],
|
||||
['Direct child', 1, 'Host A disk'],
|
||||
['UnRAID host A', 1, 'Host A disk'],
|
||||
['UnRAID host B', 1, 'Host B disk'],
|
||||
];
|
||||
for (const [phase, count, model] of phases) {
|
||||
await page.getByRole('button', { name: phase, exact: true }).click();
|
||||
const text = await detail.textContent();
|
||||
if (count) {
|
||||
await detail.getByText(`Physical Disks (${count})`, { exact: true }).waitFor();
|
||||
if (model) {
|
||||
assert.ok(text.includes(model));
|
||||
assert.ok(!text.includes(model === 'Host A disk' ? 'Host B disk' : 'Host A disk'));
|
||||
assert.ok(!text.includes('Unowned disk') && !text.includes('Suffix collision disk'));
|
||||
assert.equal(text.includes('99 errors'), model === 'Host B disk');
|
||||
assert.ok(text.includes(model === 'Host B disk' ? '82°C' : '34°C'));
|
||||
} else
|
||||
assert.ok(
|
||||
text.includes('Host B disk') && text.includes('99 errors'),
|
||||
'parent must expose foreign health',
|
||||
);
|
||||
} else assert.equal(await detail.getByText(/Physical Disks/).count(), 0);
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
width: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
const screenshot = `${parent ? 'parent-' : ''}${name}-${phase.toLowerCase().replaceAll(' ', '-')}.png`;
|
||||
await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true });
|
||||
observations.push({
|
||||
name,
|
||||
phase,
|
||||
browser: browser.version(),
|
||||
text,
|
||||
dimensions,
|
||||
screenshot,
|
||||
});
|
||||
}
|
||||
if (!parent) {
|
||||
await page.getByRole('button', { name: 'ZFS host A', exact: true }).click();
|
||||
await detail.getByRole('tab', { name: 'History', exact: true }).click();
|
||||
await detail.getByRole('img', { name: 'Usage chart', exact: true }).waitFor();
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('canvas')].some((el) =>
|
||||
document
|
||||
.getElementById(el.getAttribute('aria-describedby'))
|
||||
?.textContent.includes('1 data point'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.resourceId === 'history-a' &&
|
||||
request.metric === 'usage' &&
|
||||
request.resourceType === 'storage',
|
||||
),
|
||||
);
|
||||
await page.getByRole('button', { name: 'ZFS host B', exact: true }).click();
|
||||
await page.waitForFunction(() =>
|
||||
[...document.querySelectorAll('canvas')].some((el) =>
|
||||
document
|
||||
.getElementById(el.getAttribute('aria-describedby'))
|
||||
?.textContent.includes('1 data point'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.resourceId === 'history-b' &&
|
||||
request.metric === 'usage' &&
|
||||
request.resourceType === 'storage',
|
||||
),
|
||||
);
|
||||
await detail.getByRole('tab', { name: 'Overview', exact: true }).click();
|
||||
await detail.getByText('Physical Disks (1)', { exact: true }).waitFor();
|
||||
assert.equal(await detail.getByText('Host A disk', { exact: true }).count(), 0);
|
||||
assert.equal(await detail.getByText('Host B disk', { exact: true }).count(), 1);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({ name, phase: 'history-requests', requests, errors });
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, parent ? 'parent.json' : 'result.json'),
|
||||
JSON.stringify({ playwright, expected, parent, observations }, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify({ result: 'passed', parent, observations: observations.length }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
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>Pool ownership verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./pool-ownership.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createMemo, createSignal, For } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { StoragePoolDetail } from '../src/components/Storage/StoragePoolDetail';
|
||||
import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters';
|
||||
import type { State } from '../src/types/api';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
const modes = [
|
||||
'ZFS host A',
|
||||
'ZFS host B',
|
||||
'UnRAID host A',
|
||||
'UnRAID host B',
|
||||
'Missing ownership',
|
||||
'Direct child',
|
||||
];
|
||||
const [mode, setMode] = createSignal(modes[0]);
|
||||
const resource = createMemo(() => {
|
||||
const unraid = mode().startsWith('UnRAID');
|
||||
const b = mode().endsWith('B');
|
||||
const unknown = mode() === 'Missing ownership';
|
||||
return {
|
||||
id: b ? 'pool-b' : 'pool-a',
|
||||
type: 'storage',
|
||||
name: unraid ? 'Array' : 'tank',
|
||||
displayName: 'tank',
|
||||
platformType: unraid ? 'unraid' : 'truenas',
|
||||
platformId: 'fixture',
|
||||
sourceType: 'api',
|
||||
parentId: unknown ? undefined : b ? 'host-b' : 'host-a',
|
||||
parentName: 'Storage host',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
metricsTarget: { resourceType: 'storage', resourceId: b ? 'history-b' : 'history-a' },
|
||||
disk: { current: 25, total: 1024, used: 256, free: 768 },
|
||||
storage: unraid
|
||||
? { type: 'unraid-array', platform: 'unraid' }
|
||||
: {
|
||||
type: 'zfs-pool',
|
||||
isZfs: true,
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [{ name: 'sda', type: 'disk', state: 'ONLINE' }],
|
||||
},
|
||||
},
|
||||
} as Resource;
|
||||
});
|
||||
const record = createMemo(
|
||||
() => buildStorageRecords({ state: {} as State, resources: [resource()] })[0],
|
||||
);
|
||||
const disks = createMemo(() =>
|
||||
[
|
||||
['a', 'host-a', 'Host A disk', '/dev/sda', 34, 0],
|
||||
['b', 'host-b', 'Host B disk', '/dev/sda', 82, 99],
|
||||
['unknown', undefined, 'Unowned disk', '/dev/sda', 70, 0],
|
||||
['suffix', 'host-a', 'Suffix collision disk', '/dev/not-sda', 66, 0],
|
||||
].map(
|
||||
([id, owner, model, devPath, temperature, errorCount]) =>
|
||||
({
|
||||
id: `disk-${id}`,
|
||||
type: 'physical_disk',
|
||||
name: model,
|
||||
displayName: model,
|
||||
platformType: 'truenas',
|
||||
platformId: 'fixture',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
parentId: mode() === 'Direct child' && id === 'a' ? 'pool-a' : owner,
|
||||
parentName: 'Storage host',
|
||||
physicalDisk: {
|
||||
devPath,
|
||||
model,
|
||||
temperature,
|
||||
errorCount,
|
||||
storageGroup: id === 'suffix' ? 'other-group' : 'unraid-array',
|
||||
storageRole: 'data',
|
||||
sizeBytes: 1024,
|
||||
storageState: 'online',
|
||||
},
|
||||
}) as Resource,
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
() => (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Pool ownership verification</h1>
|
||||
<p>
|
||||
Repeated disk names and groups across synthetic hosts, using the production Storage detail.
|
||||
</p>
|
||||
<nav aria-label="Fixture selection" class="flex flex-wrap gap-2">
|
||||
<For each={modes}>
|
||||
{(value) => (
|
||||
<button
|
||||
class="min-h-11 rounded border border-border px-3"
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
<section aria-label="Pool detail">
|
||||
<h2>{mode()}</h2>
|
||||
<table class="w-full table-fixed">
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={record()}
|
||||
physicalDisks={disks()}
|
||||
summarySeriesId="ownership-pool"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,45 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "e6540da45da4de4bcb954463373d8ea9f196eb56",
|
||||
"verified_at": "2026-10-02T21:04:53Z",
|
||||
"base_sha": "689079622dfa8fb472349ad13c36ad2068f0c38e",
|
||||
"verified_at": "2026-10-02T21:27:29.856838Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx"
|
||||
"frontend-modern/src/features/storageBackups/storageAdapters.ts",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f2d8a72d62d06a89d3a70bd82d63d658ece3ac8acf3a0f688fb751a250d6f057"
|
||||
"frontend-modern/src/features/storageBackups/storageAdapters.ts": "7933ab99a1cfdf35bcebf0f351ec2b6a8c486b1281a9a3daa36f828fa657c52e",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "dd97511242f9375a725f69b3f8f40b2aee19998bdcdd2e82ce996d27f9fc519b"
|
||||
},
|
||||
"routes": [
|
||||
"/kubernetes"
|
||||
],
|
||||
"routes": ["/browser-tests/pool-ownership.html"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 900
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Mock estate (1 Kubernetes cluster, 2 nodes, 6 pods, 2 deployments) with one CronJob carrying lastSuccessfulTime, one completed Job, one DaemonSet and one StatefulSet in the Workload Controllers table, Chromium",
|
||||
"Workload Controllers table at the full desktop layout where the Detail column is shown (1440px) and at container widths where it is hidden (1280, 900, 390px)",
|
||||
"Controller row expanded to its detail disclosure at 390px"
|
||||
"Production StoragePoolDetail through the real storage adapter; desktop Chromium light and phone WebKit dark; ZFS/UnRAID host A and B, missing ownership and direct children",
|
||||
"12 recorded presentation states, plus per-engine History target replacement and Overview return"
|
||||
],
|
||||
"interactions": [
|
||||
"Measured every controllers table header and cell for clipped text (scrollWidth over clientWidth) at each viewport; before the change the Detail cells needed 258px in a 119px column, after it the CronJob row reads \"Last success 1h ago\" and the Job row \"Completed 41m ago\" with no clipped element in the column and no horizontal page scroll",
|
||||
"Read the Detail span title attributes: they carry the absolute local time (\"Last success: Oct 2, 2026, 08:51 PM\") instead of the raw RFC 3339 string",
|
||||
"Probed the rendered width of every label the column can produce (Started, Completed, Last success, Last run with ages up to 365d) against the 135px Detail cell at 1440px; all fit whole",
|
||||
"Compared before and after screenshots of the table at 1440px; the StatefulSet service name now truncates with an ellipsis inside the cell instead of being cut by the cell edge"
|
||||
"Replace the selected host in the mounted detail: a remote 82\u00b0C/99-error disk appears only on its owning host; local temperature and errors remain visible",
|
||||
"Missing ownership omits inferred Physical Disks without removing ZFS information; direct children remain",
|
||||
"History range and canonical history-a/history-b requests stay isolated while selected hosts change; no horizontal page overflow"
|
||||
],
|
||||
"command": "playwright chromium against a private mock-mode stack built from this commit (backend :7693, vite :5213)"
|
||||
"observations": {
|
||||
"playwright": "1.56.1",
|
||||
"expected_playwright": "1.56.1",
|
||||
"result": "browser/result.json",
|
||||
"parent": "browser/parent.json",
|
||||
"driver": "frontend-modern/browser-tests/pool-ownership.cjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StoragePoolDetail } from '../StoragePoolDetail';
|
||||
import { buildStorageRecords } from '@/features/storageBackups/storageAdapters';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { State } from '@/types/api';
|
||||
|
||||
vi.mock('@/components/shared/HistoryChart', () => ({
|
||||
HistoryChart: () => <div>History fixture</div>,
|
||||
}));
|
||||
|
||||
const pool = (owner: string | undefined): Resource =>
|
||||
({
|
||||
id: 'pool',
|
||||
name: 'tank',
|
||||
type: 'storage',
|
||||
platformType: 'truenas',
|
||||
parentId: owner,
|
||||
parentName: 'Identical label',
|
||||
storage: {
|
||||
type: 'zfs-pool',
|
||||
isZfs: true,
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [{ name: 'sda', type: 'disk', state: 'ONLINE' }],
|
||||
},
|
||||
},
|
||||
}) as Resource;
|
||||
|
||||
const disk = (id: string, owner: string, model: string, errors = 0): Resource =>
|
||||
({
|
||||
id,
|
||||
name: model,
|
||||
type: 'physical_disk',
|
||||
platformType: 'truenas',
|
||||
parentId: owner,
|
||||
parentName: 'Identical label',
|
||||
physicalDisk: { devPath: '/dev/sda', model, errorCount: errors, temperature: errors ? 82 : 34 },
|
||||
}) as Resource;
|
||||
|
||||
describe('Storage pool ownership through live snapshots', () => {
|
||||
it('updates mounted ownership, local warnings and removal without leaking peer disks', () => {
|
||||
const [resource, setResource] = createSignal(pool('host-a'));
|
||||
const [disks, setDisks] = createSignal([
|
||||
disk('a', 'host-a', 'Local disk'),
|
||||
disk('b', 'host-b', 'Peer disk', 99),
|
||||
]);
|
||||
const record = () => buildStorageRecords({ state: {} as State, resources: [resource()] })[0];
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail record={record()} physicalDisks={disks()} summarySeriesId="tank" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Physical Disks (1)')).toBeVisible();
|
||||
expect(screen.getByText('Local disk')).toBeVisible();
|
||||
expect(screen.queryByText('Peer disk')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('99 errors')).not.toBeInTheDocument();
|
||||
|
||||
setResource(pool('host-b'));
|
||||
expect(screen.queryByText('Local disk')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Peer disk')).toBeVisible();
|
||||
expect(screen.getByText('99 errors')).toBeVisible();
|
||||
expect(screen.getByText('82°C')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
|
||||
expect(screen.getByText('Peer disk')).toBeVisible();
|
||||
|
||||
setResource(pool(undefined));
|
||||
expect(screen.queryByText(/Physical Disks/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('ZFS Pool')).toBeVisible();
|
||||
setResource(pool('host-a'));
|
||||
setDisks([disk('a', 'host-a', 'Local disk', 4)]);
|
||||
expect(screen.getByText('Local disk')).toBeVisible();
|
||||
expect(screen.getByText('4 errors')).toBeVisible();
|
||||
setDisks([]);
|
||||
expect(screen.queryByText(/Physical Disks/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('4 errors')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -238,6 +238,7 @@ describe('StoragePoolDetail', () => {
|
||||
[
|
||||
{
|
||||
id: 'disk1',
|
||||
parentId: 'storage-1',
|
||||
type: 'physical_disk',
|
||||
name: 'Disk 1',
|
||||
displayName: 'Disk 1',
|
||||
|
||||
@@ -23,6 +23,7 @@ const buildRecord = (): StorageRecord => ({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
|
||||
details: {
|
||||
node: 'truenas01',
|
||||
parentId: 'host-truenas01',
|
||||
type: 'pool',
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
@@ -38,6 +39,7 @@ const buildRecord = (): StorageRecord => ({
|
||||
const buildDisk = (): Resource =>
|
||||
({
|
||||
id: 'disk-1',
|
||||
parentId: 'host-truenas01',
|
||||
type: 'physical_disk',
|
||||
name: 'disk-1',
|
||||
displayName: 'disk-1',
|
||||
|
||||
+6
-2
@@ -453,16 +453,18 @@ describe('getStoragePoolLinkedDisks branch coverage', () => {
|
||||
id: 'cache-pool-1',
|
||||
name: ' My Cache ',
|
||||
source: unraidSource(),
|
||||
details: { type: 'unraid-cache-pool', platform: 'unraid' },
|
||||
details: { type: 'unraid-cache-pool', platform: 'unraid', parentId: 'host-1' },
|
||||
});
|
||||
const disks = [
|
||||
{
|
||||
id: 'in',
|
||||
parentId: 'host-1',
|
||||
name: 'in',
|
||||
physicalDisk: { devPath: '/dev/sda', storageRole: 'cache', storageGroup: 'My Cache' },
|
||||
},
|
||||
{
|
||||
id: 'out',
|
||||
parentId: 'host-1',
|
||||
name: 'out',
|
||||
physicalDisk: { devPath: '/dev/sdb', storageRole: 'cache', storageGroup: 'other' },
|
||||
},
|
||||
@@ -476,16 +478,18 @@ describe('getStoragePoolLinkedDisks branch coverage', () => {
|
||||
id: 'arr-1',
|
||||
name: 'Tower',
|
||||
source: unraidSource(),
|
||||
details: { type: 'pool', platform: 'unraid', topology: 'array' },
|
||||
details: { type: 'pool', platform: 'unraid', topology: 'array', parentId: 'host-1' },
|
||||
});
|
||||
const disks = [
|
||||
{
|
||||
id: 'in',
|
||||
parentId: 'host-1',
|
||||
name: 'in',
|
||||
physicalDisk: { devPath: '/dev/sda', storageRole: 'data', storageGroup: 'unraid-array' },
|
||||
},
|
||||
{
|
||||
id: 'out',
|
||||
parentId: 'host-1',
|
||||
name: 'out',
|
||||
physicalDisk: { devPath: '/dev/sdb', storageRole: 'data', storageGroup: 'nope' },
|
||||
},
|
||||
|
||||
+5
@@ -30,6 +30,7 @@ const buildRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => (
|
||||
observedAt: Date.now(),
|
||||
details: {
|
||||
node: 'truenas01',
|
||||
parentId: 'host-truenas01',
|
||||
type: 'pool',
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
@@ -66,6 +67,7 @@ const buildRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => (
|
||||
const buildDisk = (): Resource =>
|
||||
({
|
||||
id: 'disk-1',
|
||||
parentId: 'host-truenas01',
|
||||
type: 'physical_disk',
|
||||
name: 'disk-1',
|
||||
displayName: 'disk-1',
|
||||
@@ -249,6 +251,7 @@ describe('storagePoolDetailPresentation', () => {
|
||||
},
|
||||
details: {
|
||||
type: 'unraid-array',
|
||||
parentId: 'host-tower',
|
||||
platform: 'unraid',
|
||||
topology: 'array',
|
||||
arrayState: 'STARTED',
|
||||
@@ -257,6 +260,7 @@ describe('storagePoolDetailPresentation', () => {
|
||||
const disks = [
|
||||
{
|
||||
id: 'parity',
|
||||
parentId: 'host-tower',
|
||||
type: 'physical_disk',
|
||||
name: 'Parity',
|
||||
displayName: 'Parity',
|
||||
@@ -277,6 +281,7 @@ describe('storagePoolDetailPresentation', () => {
|
||||
},
|
||||
{
|
||||
id: 'disk1',
|
||||
parentId: 'host-tower',
|
||||
type: 'physical_disk',
|
||||
name: 'Disk 1',
|
||||
displayName: 'Disk 1',
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { State } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { buildStorageRecords } from '../storageAdapters';
|
||||
import { getStoragePoolLinkedDisks } from '../storagePoolDetailPresentation';
|
||||
|
||||
const storage = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'pool-a',
|
||||
type: 'storage',
|
||||
name: 'tank',
|
||||
displayName: 'tank',
|
||||
platformId: 'provider-a',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: 1,
|
||||
parentId: 'host-a',
|
||||
parentName: 'Same display name',
|
||||
storage: {
|
||||
type: 'zfspool',
|
||||
isZfs: true,
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [{ name: 'sda', type: 'disk', state: 'ONLINE' }],
|
||||
},
|
||||
},
|
||||
proxmox: { nodeName: 'pve1', instance: 'cluster-a' },
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const disk = (id: string, overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id,
|
||||
type: 'physical_disk',
|
||||
name: id,
|
||||
displayName: id,
|
||||
platformId: 'provider-a',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: 1,
|
||||
parentId: 'host-a',
|
||||
parentName: 'Same display name',
|
||||
proxmox: { nodeName: 'pve1', instance: 'cluster-a' },
|
||||
physicalDisk: { devPath: '/dev/sda', model: id, temperature: 35 },
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const record = (resource = storage()) =>
|
||||
buildStorageRecords({ state: {} as State, resources: [resource] })[0];
|
||||
const linked = (resource: Resource, disks: Resource[]) =>
|
||||
getStoragePoolLinkedDisks(record(resource), disks).map((item) => item.id);
|
||||
|
||||
describe('pool physical disk ownership', () => {
|
||||
it('excludes other hosts and unknown owners despite identical paths and labels', () => {
|
||||
expect(
|
||||
linked(storage(), [
|
||||
disk('local'),
|
||||
disk('remote', { parentId: 'host-b' }),
|
||||
disk('unknown', { parentId: undefined, proxmox: undefined }),
|
||||
]),
|
||||
).toEqual(['local']);
|
||||
});
|
||||
|
||||
it('never lets matching provider names override mismatched canonical parents', () => {
|
||||
expect(linked(storage(), [disk('remote', { parentId: 'host-b' })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves direct canonical pool and legacy resource-reference children', () => {
|
||||
const target = record();
|
||||
target.refs = { resourceId: 'legacy-pool' };
|
||||
expect(
|
||||
getStoragePoolLinkedDisks(target, [
|
||||
disk('direct', { parentId: 'pool-a', physicalDisk: { devPath: '/dev/other' } }),
|
||||
disk('legacy', { parentId: 'legacy-pool', physicalDisk: { devPath: '/dev/legacy' } }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(['legacy', 'direct']);
|
||||
});
|
||||
|
||||
it.each(['top-level', 'legacy'] as const)(
|
||||
'retains exact native instance/node ownership: %s',
|
||||
(shape) => {
|
||||
const pool = storage({ parentId: undefined, proxmox: undefined, platformData: undefined });
|
||||
const local = disk('local', { parentId: undefined, proxmox: undefined });
|
||||
const native = { nodeName: 'pve1', instance: 'cluster-a' };
|
||||
if (shape === 'top-level') {
|
||||
pool.proxmox = native;
|
||||
local.proxmox = native;
|
||||
} else {
|
||||
pool.platformData = { proxmox: native };
|
||||
local.platformData = { proxmox: native };
|
||||
}
|
||||
expect(record(pool).details?.instance).toBe('cluster-a');
|
||||
expect(
|
||||
linked(pool, [
|
||||
local,
|
||||
disk('other-cluster', {
|
||||
parentId: undefined,
|
||||
proxmox: { ...native, instance: 'cluster-b' },
|
||||
}),
|
||||
disk('other-node', { parentId: undefined, proxmox: { ...native, nodeName: 'pve2' } }),
|
||||
disk('unscoped', { parentId: undefined, proxmox: { nodeName: 'pve1' } }),
|
||||
]),
|
||||
).toEqual(['local']);
|
||||
},
|
||||
);
|
||||
|
||||
it('requires both native instance and node when canonical ownership is missing', () => {
|
||||
expect(
|
||||
linked(storage({ parentId: undefined, proxmox: { nodeName: 'pve1' } }), [
|
||||
disk('unscoped', { parentId: undefined, proxmox: { nodeName: 'pve1' } }),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['sda', '/dev/sda'])('matches whole device identifiers, not a suffix: %s', (name) => {
|
||||
const pool = storage();
|
||||
pool.storage!.zfsPool!.devices = [
|
||||
{ name, type: 'disk', state: 'ONLINE', readErrors: 0, writeErrors: 0, checksumErrors: 0 },
|
||||
];
|
||||
expect(
|
||||
linked(pool, [
|
||||
disk('local'),
|
||||
disk('suffix-collision', { physicalDisk: { devPath: '/dev/not-sda' } }),
|
||||
disk('partition', { physicalDisk: { devPath: '/dev/sda1' } }),
|
||||
]),
|
||||
).toEqual(['local']);
|
||||
});
|
||||
|
||||
it('does not confuse nvme device numbers with a shorter vdev name', () => {
|
||||
const pool = storage();
|
||||
pool.storage!.zfsPool!.devices = [
|
||||
{
|
||||
name: 'n1',
|
||||
type: 'disk',
|
||||
state: 'ONLINE',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
},
|
||||
];
|
||||
expect(linked(pool, [disk('nvme', { physicalDisk: { devPath: '/dev/nvme0n1' } })])).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unraid-array', 'Array', 'unraid-array'],
|
||||
['unraid-cache-pool', 'cache', 'cache'],
|
||||
])('keeps repeated UnRAID groups on their own host: %s', (type, name, group) => {
|
||||
const pool = storage({
|
||||
name,
|
||||
platformType: 'unraid',
|
||||
proxmox: undefined,
|
||||
storage: { type, platform: 'unraid' },
|
||||
});
|
||||
const member = { devPath: '/dev/sda', storageGroup: group, storageRole: 'data' };
|
||||
expect(
|
||||
linked(pool, [
|
||||
disk('local', { physicalDisk: member }),
|
||||
disk('remote', { parentId: 'host-b', physicalDisk: member }),
|
||||
disk('unknown', { parentId: undefined, proxmox: undefined, physicalDisk: member }),
|
||||
disk('different-group', { physicalDisk: { ...member, storageGroup: 'other' } }),
|
||||
]),
|
||||
).toEqual(['local']);
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,10 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag
|
||||
parentId: resource.parentId,
|
||||
parentName: resource.parentName,
|
||||
node: proxmoxNativeNode || (platformData.node as string | undefined) || storageNodes[0],
|
||||
instance:
|
||||
resource.proxmox?.instance ||
|
||||
(proxmoxPlatform?.instance as string | undefined) ||
|
||||
(platformData.instance as string | undefined),
|
||||
nodeHints,
|
||||
hostLabel,
|
||||
platformLabel,
|
||||
|
||||
@@ -368,6 +368,28 @@ const diskRoleRank = (role: string): number => {
|
||||
}
|
||||
};
|
||||
|
||||
const diskBelongsToStorageHost = (record: StorageRecord, disk: Resource): boolean => {
|
||||
const hostId = readRecordDetailString(record, 'parentId');
|
||||
const diskParentId = disk.parentId?.trim();
|
||||
// Canonical ownership outranks labels, paths and source-native names.
|
||||
// In particular, two hosts may both be called pve1 and both report /dev/sda.
|
||||
if (hostId && diskParentId) return hostId === diskParentId;
|
||||
|
||||
// Older Proxmox snapshots may lack canonical parents. Only the complete
|
||||
// native instance/node pair is a scope; a hostname alone is not one.
|
||||
const platformData = (disk.platformData || {}) as Record<string, unknown>;
|
||||
const proxmox = platformData.proxmox as Resource['proxmox'] | undefined;
|
||||
const node = readRecordDetailString(record, 'node').toLowerCase();
|
||||
const instance = readRecordDetailString(record, 'instance');
|
||||
const diskNode = (disk.proxmox?.nodeName || disk.proxmox?.node || proxmox?.nodeName || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const diskInstance = (disk.proxmox?.instance || proxmox?.instance || '').trim();
|
||||
return Boolean(node && instance && node === diskNode && instance === diskInstance);
|
||||
};
|
||||
|
||||
const normalizeDevicePath = (value: string): string => value.trim().replace(/^\/dev\//, '');
|
||||
|
||||
export function getStoragePoolLinkedDisks(
|
||||
record: StorageRecord,
|
||||
physicalDisks: Resource[],
|
||||
@@ -382,9 +404,14 @@ export function getStoragePoolLinkedDisks(
|
||||
.filter((disk) => {
|
||||
const devPath = readDiskDevPath(disk);
|
||||
const group = readDiskStorageGroup(disk).toLowerCase();
|
||||
if (directParentIds.has(disk.parentId || '')) return true;
|
||||
if (directParentIds.has(disk.parentId?.trim() || '')) return true;
|
||||
if (!diskBelongsToStorageHost(record, disk)) return false;
|
||||
if (pool?.devices?.length && devPath) {
|
||||
return pool.devices.some((device) => devPath.endsWith(device.name));
|
||||
return pool.devices.some(
|
||||
(device) =>
|
||||
Boolean(device.name.trim()) &&
|
||||
normalizeDevicePath(devPath) === normalizeDevicePath(device.name),
|
||||
);
|
||||
}
|
||||
return Boolean(unraidStorageGroup && group === unraidStorageGroup);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user