Fix Proxmox physical disk inventory continuity

This commit is contained in:
rcourtman 2026-07-23 22:00:29 +01:00
parent b14cb376bc
commit 6a527ba9f2
46 changed files with 1908 additions and 376 deletions

View file

@ -61,6 +61,30 @@ describe('diskResourceUtils', () => {
instance: 'cluster-main',
}),
).toBe(true);
expect(
matchesPhysicalDiskNode(buildDisk(), {
id: 'other-node-id',
name: 'tower',
instance: 'cluster-secondary',
}),
).toBe(false);
expect(
matchesPhysicalDiskNode(buildDisk(), {
id: 'other-node-id',
name: 'tower',
}),
).toBe(false);
expect(
matchesPhysicalDiskNode(
buildDisk({
platformData: { proxmox: { nodeName: 'tower' } } as any,
}),
{
id: 'legacy-node-id',
name: 'tower',
},
),
).toBe(true);
});
it('resolves physical disk metric targets through linked agents when needed', () => {

View file

@ -48,11 +48,11 @@ export const matchesPhysicalDiskNode = (
const diskInstance = normalize(diskIdentity.instance);
const targetInstance = normalize(target.instance);
if (!diskInstance || !targetInstance) {
if (!diskInstance && !targetInstance) {
return true;
}
return diskInstance === targetInstance;
return Boolean(diskInstance && targetInstance && diskInstance === targetInstance);
};
export const resolvePhysicalDiskMetricResourceId = (

View file

@ -174,7 +174,7 @@ describe('diskPresentation', () => {
'',
);
expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: 96 }))).toBe('96%');
expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: 0 }))).toBe('');
expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: 0 }))).toBe('0%');
expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: -1 }))).toBe('');
expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 96 }))).toContain('text-green');
expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 35 }))).toContain('text-amber');
@ -381,6 +381,20 @@ describe('diskPresentation', () => {
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'tank')).toBe(true);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'node:tower')).toBe(true);
expect(matchesPhysicalDiskSearch(warningDisk, warningData, 'node:pve1')).toBe(false);
expect(
matchesPhysicalDiskSearch(
warningDisk,
{
...warningData,
wwn: '5-c500-da60ca43',
controller: '/dev/sg2',
target: 'sssraid,0,1',
type: 'sas',
instance: 'cluster-a',
},
'5-c500 sssraid cluster-a sas',
),
).toBe(true);
expect(getPhysicalDiskSourceKey(warningDisk)).toBe('proxmox-pve');
expect(getPhysicalDiskRoleFilterValue(warningData)).toBe('cache-pool');
expect(normalizePhysicalDiskFacetFilter(' Cache Pool ')).toBe('cache-pool');

View file

@ -43,6 +43,7 @@ export interface PhysicalDiskPresentationData {
instance: string;
devPath: string;
model: string;
vendor?: string;
serial: string;
wwn: string;
size: number;
@ -304,6 +305,7 @@ export function extractPhysicalDiskPresentationData(
instance: diskNode.instance,
devPath: pd.devPath || '',
model: pd.model || resource.name || '',
vendor: pd.vendor || '',
serial: pd.serial || '',
wwn: pd.wwn || '',
type: pd.diskType || '',
@ -404,9 +406,15 @@ export function matchesPhysicalDiskSearch(
if (parsed.freeTerms.length === 0) return true;
const haystack = [
disk.model,
disk.vendor,
disk.devPath,
disk.serial,
disk.wwn,
disk.node,
disk.instance,
disk.type,
disk.controller,
disk.target,
getPhysicalDiskRoleLabel(disk),
getPhysicalDiskParentLabel(disk),
getPhysicalDiskPlatformLabel(
@ -534,7 +542,7 @@ export function getPhysicalDiskHealthStatus(
const criticalRisk = (disk.riskLevel || '').trim().toLowerCase() === 'critical';
const warningRisk = (disk.riskLevel || '').trim().toLowerCase() === 'warning';
const smartWarning = hasPhysicalDiskSmartWarning(disk);
const lowLife = disk.wearout > 0 && disk.wearout < 10;
const lowLife = disk.wearout >= 0 && disk.wearout < 10;
if (normalizedHealth === 'FAILED' || criticalRisk) {
return {
@ -625,13 +633,13 @@ export function getPhysicalDiskParentLabel(disk: PhysicalDiskPresentationData):
}
export function getPhysicalDiskLifeLabel(disk: PhysicalDiskPresentationData): string {
// Wearout is "% life remaining" (100 = new); 0 and -1 both mean unreported.
if (typeof disk.wearout !== 'number' || disk.wearout <= 0) return '';
// Wearout is "% life remaining" (100 = new); only negative values are unreported.
if (typeof disk.wearout !== 'number' || disk.wearout < 0) return '';
return `${Math.min(disk.wearout, 100)}%`;
}
export function getPhysicalDiskLifeTextClass(disk: PhysicalDiskPresentationData): string {
if (typeof disk.wearout !== 'number' || disk.wearout <= 0) {
if (typeof disk.wearout !== 'number' || disk.wearout < 0) {
return PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS;
}
if (disk.wearout < 20) return 'text-red-600 dark:text-red-400';

View file

@ -330,6 +330,63 @@ describe('useUnifiedResources', () => {
dispose();
});
it('loads physical disks beyond the first resource page without dropping wide-node inventory', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
...v2Resource,
id: `disk-${index + 1}`,
type: 'physical_disk',
name: `disk-${index + 1}`,
physicalDisk: {
devPath: `/dev/sd${index + 1}`,
serial: `SERIAL-${index + 1}`,
diskType: index === 0 ? 'nvme' : 'sas',
},
}));
const secondPage = Array.from({ length: 25 }, (_, index) => ({
...v2Resource,
id: `disk-${index + 101}`,
type: 'physical_disk',
name: `disk-${index + 101}`,
physicalDisk: {
devPath: `/dev/sg${index + 101}`,
serial: `SERIAL-${index + 101}`,
diskType: 'sas',
},
}));
apiFetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: firstPage, meta: { totalPages: 2 } }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ data: secondPage, meta: { totalPages: 2 } }),
});
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query: 'type=physical_disk',
cacheKey: 'issue-1516-wide-node',
});
});
await waitForResourceCount(() => result!.resources().length, 125);
expect(result!.resources()).toHaveLength(125);
expect(result!.resources().some((resource) => resource.id === 'disk-125')).toBe(true);
expect(apiFetchMock).toHaveBeenNthCalledWith(
2,
'/api/resources?type=physical_disk&page=2&limit=100',
{ cache: 'no-store' },
);
dispose();
});
it('prefers websocket initial hydration over an immediate REST fetch for dashboard snapshots', async () => {
setWsConnected(false);
setWsInitialDataReceived(false);
@ -2045,6 +2102,7 @@ describe('useUnifiedResources', () => {
expect(useUnifiedResourcesSource).not.toContain("const DEFAULT_ORG_SCOPE = 'default'");
expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope =');
expect(useUnifiedResourcesSource).toContain('asTrimmedString');
expect(useUnifiedResourcesSource).not.toContain('UNIFIED_RESOURCES_MAX_PAGES');
expect(useUnifiedResourcesSource).not.toContain(
'const asTrimmedString = (value: unknown): string | undefined => {',
);

View file

@ -19,6 +19,7 @@ import type {
ResourceAvailabilityMeta,
ResourcePBSMeta,
ResourcePolicyPostureSummary,
ResourcePhysicalDiskMeta,
ResourceStatus,
ResourceStorageMeta,
ResourceStorageRisk,
@ -46,7 +47,6 @@ const DEFAULT_UNIFIED_RESOURCES_QUERY =
const STORAGE_RECOVERY_UNIFIED_RESOURCES_QUERY =
'type=storage,pbs,pmg,vm,system-container,pod,agent,k8s-cluster,k8s-node,physical_disk,ceph';
const UNIFIED_RESOURCES_PAGE_LIMIT = 100;
const UNIFIED_RESOURCES_MAX_PAGES = 20;
const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000;
const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800;
const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500;
@ -404,31 +404,7 @@ type APIResource = {
availabilityChecks?: ResourceAvailabilityMeta[];
recentChanges?: ResourceChange[];
facetCounts?: ResourceFacetCounts;
physicalDisk?: {
devPath?: string;
model?: string;
serial?: string;
wwn?: string;
diskType?: string;
sizeBytes?: number;
health?: string;
wearout?: number;
temperature?: number;
rpm?: number;
used?: string;
smart?: {
powerOnHours?: number;
powerCycles?: number;
reallocatedSectors?: number;
pendingSectors?: number;
offlineUncorrectable?: number;
udmaCrcErrors?: number;
percentageUsed?: number;
availableSpare?: number;
mediaErrors?: number;
unsafeShutdowns?: number;
};
};
physicalDisk?: ResourcePhysicalDiskMeta;
ceph?: {
fsid?: string;
healthStatus?: string;
@ -1098,7 +1074,7 @@ async function fetchUnifiedResources(query: string): Promise<UnifiedResourcesSna
let policyPosture: ResourcePolicyPostureSummary | null = null;
let totalPages = 1;
for (let page = 1; page <= totalPages && page <= UNIFIED_RESOURCES_MAX_PAGES; page += 1) {
for (let page = 1; page <= totalPages; page += 1) {
const response = await apiFetch(buildUnifiedResourcesUrl(normalizedQuery, page), {
cache: 'no-store',
});

View file

@ -510,6 +510,7 @@ export interface ResourceCephMeta {
export interface ResourcePhysicalDiskMeta {
devPath?: string;
model?: string;
vendor?: string;
serial?: string;
wwn?: string;
diskType?: string;