mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Fix Proxmox physical disk inventory continuity
This commit is contained in:
parent
b14cb376bc
commit
6a527ba9f2
46 changed files with 1908 additions and 376 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 => {',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -510,6 +510,7 @@ export interface ResourceCephMeta {
|
|||
export interface ResourcePhysicalDiskMeta {
|
||||
devPath?: string;
|
||||
model?: string;
|
||||
vendor?: string;
|
||||
serial?: string;
|
||||
wwn?: string;
|
||||
diskType?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue