mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 18:55:40 +00:00
Add native TrueNAS storage details
This commit is contained in:
+53
@@ -238,4 +238,57 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
expect(section.getByText('Read/write')).toBeInTheDocument();
|
||||
expect(section.getByText('media')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens native TrueNAS storage detail immediately for inline storage rows', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-pool-1',
|
||||
type: 'storage',
|
||||
displayName: 'archive',
|
||||
status: 'degraded',
|
||||
platformScopes: ['truenas'],
|
||||
disk: {
|
||||
current: 35.2,
|
||||
used: 25.3 * 1024 ** 4,
|
||||
total: 72 * 1024 ** 4,
|
||||
},
|
||||
childCount: 4,
|
||||
storage: {
|
||||
type: 'zfs-pool',
|
||||
topology: 'pool',
|
||||
platform: 'truenas',
|
||||
protection: 'zfs',
|
||||
zfsPoolState: 'DEGRADED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{
|
||||
code: 'zfs_pool_state',
|
||||
severity: 'warning',
|
||||
summary: 'ZFS pool archive is DEGRADED',
|
||||
},
|
||||
],
|
||||
},
|
||||
riskSummary: 'ZFS pool archive is DEGRADED',
|
||||
protectionReduced: true,
|
||||
protectionSummary: 'ZFS pool archive is DEGRADED',
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={resource}
|
||||
presentation="table-row"
|
||||
initialShowTrueNASDetails
|
||||
/>
|
||||
));
|
||||
|
||||
expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(getByTestId('resource-truenas-details-section').querySelector('table')).toBeTruthy();
|
||||
expect(section.getByText('Storage')).toBeInTheDocument();
|
||||
expect(section.getByText('Capacity')).toBeInTheDocument();
|
||||
expect(section.getByText('Health')).toBeInTheDocument();
|
||||
expect(section.getByText('ZFS')).toBeInTheDocument();
|
||||
expect(section.getAllByText('ZFS pool archive is DEGRADED')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
+77
@@ -111,4 +111,81 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
'Clients',
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes native TrueNAS storage pool metadata for the detail drawer', () => {
|
||||
const resource = baseResource({
|
||||
type: 'storage',
|
||||
displayName: 'archive',
|
||||
status: 'degraded',
|
||||
platformScopes: ['truenas'],
|
||||
disk: {
|
||||
current: 35.2,
|
||||
used: 25.3 * 1024 ** 4,
|
||||
total: 72 * 1024 ** 4,
|
||||
},
|
||||
childCount: 4,
|
||||
storage: {
|
||||
type: 'zfs-pool',
|
||||
topology: 'pool',
|
||||
platform: 'truenas',
|
||||
protection: 'zfs',
|
||||
zfsPoolState: 'DEGRADED',
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
{
|
||||
code: 'zfs_pool_state',
|
||||
severity: 'warning',
|
||||
summary: 'ZFS pool archive is DEGRADED',
|
||||
},
|
||||
],
|
||||
},
|
||||
riskSummary: 'ZFS pool archive is DEGRADED',
|
||||
protectionReduced: true,
|
||||
protectionSummary: 'ZFS pool archive is DEGRADED',
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildTrueNASDetailsSummary(resource)).toBe(
|
||||
'Pool, DEGRADED, 25.3 TB / 72.0 TB, ZFS pool archive is DEGRADED',
|
||||
);
|
||||
expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
|
||||
'Storage',
|
||||
'Capacity',
|
||||
'Health',
|
||||
]);
|
||||
expect(buildTrueNASDetailSections(resource)[0]?.rows).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ label: 'Protection', value: 'ZFS' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('summarizes native TrueNAS physical disk metadata for the detail drawer', () => {
|
||||
const resource = baseResource({
|
||||
type: 'physical_disk',
|
||||
displayName: 'sdc',
|
||||
platformScopes: ['truenas'],
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sdc',
|
||||
model: 'WD Red Pro',
|
||||
serial: 'WD-WX12A3456',
|
||||
diskType: 'sata',
|
||||
sizeBytes: 24 * 1024 ** 4,
|
||||
health: 'DEGRADED',
|
||||
temperature: 39,
|
||||
rpm: 7200,
|
||||
smart: {
|
||||
powerOnHours: 10_240,
|
||||
reallocatedSectors: 4,
|
||||
pendingSectors: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildTrueNASDetailsSummary(resource)).toBe('SATA, Degraded, 24.0 TB, 39°C');
|
||||
expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
|
||||
'Disk',
|
||||
'Health',
|
||||
'SMART',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
Resource,
|
||||
ResourcePhysicalDiskMeta,
|
||||
ResourceStorageMeta,
|
||||
ResourceTrueNASAppMeta,
|
||||
ResourceTrueNASAppPort,
|
||||
ResourceTrueNASShareMeta,
|
||||
@@ -56,6 +58,16 @@ const formatBytes = (bytes?: number): string | null => {
|
||||
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatPercent = (percent?: number): string | null => {
|
||||
if (typeof percent !== 'number' || !Number.isFinite(percent)) return null;
|
||||
return `${percent.toFixed(percent >= 10 ? 1 : 2)}%`;
|
||||
};
|
||||
|
||||
const formatTemperature = (celsius?: number): string | null => {
|
||||
if (typeof celsius !== 'number' || !Number.isFinite(celsius) || celsius <= 0) return null;
|
||||
return `${celsius.toFixed(0)}°C`;
|
||||
};
|
||||
|
||||
const formatCount = (value: number, singular: string, plural = `${singular}s`): string =>
|
||||
`${new Intl.NumberFormat().format(value)} ${value === 1 ? singular : plural}`;
|
||||
|
||||
@@ -102,6 +114,199 @@ const compactSections = (
|
||||
Boolean(section && section.rows.length > 0),
|
||||
);
|
||||
|
||||
const isTrueNASScopedResource = (resource: Resource): boolean =>
|
||||
resource.platformType === 'truenas' ||
|
||||
resource.platformScopes?.includes('truenas') === true ||
|
||||
resource.sources?.includes('truenas') === true ||
|
||||
resource.storage?.platform === 'truenas' ||
|
||||
resource.tags?.includes('truenas') === true;
|
||||
|
||||
const storageKindLabel = (storage: ResourceStorageMeta): string | null => {
|
||||
const topology = asString(storage.topology);
|
||||
if (topology) return normalizeDelimitedLabel(topology);
|
||||
return normalizeDelimitedLabel(storage.type);
|
||||
};
|
||||
|
||||
const storageStateLabel = (resource: Resource, storage: ResourceStorageMeta): string | null =>
|
||||
asString(storage.zfsPoolState) ??
|
||||
normalizeDelimitedLabel(storage.arrayState) ??
|
||||
normalizeDelimitedLabel(resource.status);
|
||||
|
||||
const storageStateTone = (
|
||||
resource: Resource,
|
||||
storage: ResourceStorageMeta,
|
||||
): ResourceDetailDrawerTrueNASRowTone => {
|
||||
const state = (storage.zfsPoolState ?? storage.arrayState ?? resource.status).toLowerCase();
|
||||
if (state === 'online' || state === 'healthy' || state === 'mounted') return 'success';
|
||||
if (state === 'degraded' || state === 'warning' || state === 'offline') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const storageUsageLabel = (resource: Resource): string | null => {
|
||||
const used = formatBytes(resource.disk?.used);
|
||||
const total = formatBytes(resource.disk?.total);
|
||||
if (used && total) return `${used} / ${total}`;
|
||||
return formatPercent(resource.disk?.current);
|
||||
};
|
||||
|
||||
const storageProtectionLabel = (value?: string): string | null => {
|
||||
const protection = asString(value);
|
||||
if (!protection) return null;
|
||||
const normalized = protection.toLowerCase();
|
||||
if (normalized === 'zfs' || normalized.startsWith('raidz')) return normalized.toUpperCase();
|
||||
return normalizeDelimitedLabel(protection);
|
||||
};
|
||||
|
||||
const buildTrueNASStorageSections = (
|
||||
resource: Resource,
|
||||
storage: ResourceStorageMeta,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
const riskReasons = (storage.risk?.reasons ?? [])
|
||||
.map((reason) => asString(reason.summary))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const storageRows = compactRows([
|
||||
row('Kind', storageKindLabel(storage)),
|
||||
row('State', storageStateLabel(resource, storage), {
|
||||
tone: storageStateTone(resource, storage),
|
||||
}),
|
||||
row('Pool', asString(storage.pool) ?? asString(resource.parentName)),
|
||||
row('Path', asString(storage.path)),
|
||||
row('Protection', storageProtectionLabel(storage.protection)),
|
||||
row('Shared', yesNoValue(storage.shared)),
|
||||
]);
|
||||
|
||||
const capacityRows = compactRows([
|
||||
row('Usage', storageUsageLabel(resource)),
|
||||
row('Used', formatBytes(resource.disk?.used)),
|
||||
row('Total', formatBytes(resource.disk?.total)),
|
||||
row('Percent', formatPercent(resource.disk?.current)),
|
||||
row('Children', formatInteger(resource.childCount)),
|
||||
row('Consumers', formatInteger(storage.consumerCount)),
|
||||
]);
|
||||
|
||||
const healthRows = compactRows([
|
||||
row('Risk', normalizeDelimitedLabel(storage.risk?.level), {
|
||||
tone: storage.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
row('Risk summary', asString(storage.riskSummary), { tone: 'warning' }),
|
||||
row('Posture', asString(storage.postureSummary), {
|
||||
tone: storage.postureSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Protection reduced', booleanValue(storage.protectionReduced), {
|
||||
tone: storage.protectionReduced ? 'warning' : 'success',
|
||||
}),
|
||||
row('Protection summary', asString(storage.protectionSummary), {
|
||||
tone: storage.protectionSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Rebuild', asString(storage.rebuildSummary)),
|
||||
row('Reasons', summarizeList(riskReasons, 2), {
|
||||
title: riskReasons.join(', '),
|
||||
tone: 'warning',
|
||||
}),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
{ label: 'Storage', rows: storageRows },
|
||||
{ label: 'Capacity', rows: capacityRows },
|
||||
{ label: 'Health', rows: healthRows },
|
||||
]);
|
||||
};
|
||||
|
||||
const diskStateTone = (disk: ResourcePhysicalDiskMeta): ResourceDetailDrawerTrueNASRowTone => {
|
||||
const health = disk.health?.toLowerCase();
|
||||
if (health === 'passed' || health === 'healthy' || health === 'ok') return 'success';
|
||||
if (health === 'degraded' || health === 'failed' || health === 'warning') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const diskTypeLabel = (value?: string): string | null => {
|
||||
const diskType = asString(value);
|
||||
if (!diskType) return null;
|
||||
const normalized = diskType.toLowerCase();
|
||||
if (normalized === 'nvme') return 'NVMe';
|
||||
if (['sata', 'sas', 'ssd', 'hdd'].includes(normalized)) return normalized.toUpperCase();
|
||||
return normalizeDelimitedLabel(diskType);
|
||||
};
|
||||
|
||||
const formatDiskHours = (hours?: number): string | null => {
|
||||
const value = asPositiveNumber(hours);
|
||||
if (!value) return null;
|
||||
return `${formatInteger(value) ?? value.toFixed(0)}h`;
|
||||
};
|
||||
|
||||
const buildTrueNASDiskSections = (
|
||||
resource: Resource,
|
||||
disk: ResourcePhysicalDiskMeta,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
const riskReasons = (disk.risk?.reasons ?? [])
|
||||
.map((reason) => asString(reason.summary))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const identityRows = compactRows([
|
||||
row('Device', asString(disk.devPath) ?? asString(resource.name)),
|
||||
row('Model', asString(disk.model)),
|
||||
row('Serial', asString(disk.serial)),
|
||||
row('WWN', asString(disk.wwn)),
|
||||
row('Type', diskTypeLabel(disk.diskType)),
|
||||
row('Size', formatBytes(disk.sizeBytes)),
|
||||
]);
|
||||
|
||||
const healthRows = compactRows([
|
||||
row('Health', normalizeDelimitedLabel(disk.health), { tone: diskStateTone(disk) }),
|
||||
row('Temperature', formatTemperature(disk.temperature), {
|
||||
tone: disk.temperature && disk.temperature >= 55 ? 'warning' : 'default',
|
||||
}),
|
||||
row(
|
||||
'Wearout',
|
||||
disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
|
||||
),
|
||||
row('RPM', formatInteger(disk.rpm)),
|
||||
row('Role', normalizeDelimitedLabel(disk.storageRole)),
|
||||
row('Group', asString(disk.storageGroup) ?? asString(resource.parentName)),
|
||||
row('State', normalizeDelimitedLabel(disk.storageState)),
|
||||
row('Spun down', yesNoValue(disk.spunDown)),
|
||||
]);
|
||||
|
||||
const smartRows = compactRows([
|
||||
row('Power on', formatDiskHours(disk.smart?.powerOnHours)),
|
||||
row('Power cycles', formatInteger(disk.smart?.powerCycles)),
|
||||
row('Reallocated', formatInteger(disk.smart?.reallocatedSectors), {
|
||||
tone: disk.smart?.reallocatedSectors ? 'warning' : 'default',
|
||||
}),
|
||||
row('Pending sectors', formatInteger(disk.smart?.pendingSectors), {
|
||||
tone: disk.smart?.pendingSectors ? 'warning' : 'default',
|
||||
}),
|
||||
row('Offline uncorrectable', formatInteger(disk.smart?.offlineUncorrectable), {
|
||||
tone: disk.smart?.offlineUncorrectable ? 'warning' : 'default',
|
||||
}),
|
||||
row('CRC errors', formatInteger(disk.smart?.udmaCrcErrors), {
|
||||
tone: disk.smart?.udmaCrcErrors ? 'warning' : 'default',
|
||||
}),
|
||||
row('Media errors', formatInteger(disk.smart?.mediaErrors), {
|
||||
tone: disk.smart?.mediaErrors ? 'warning' : 'default',
|
||||
}),
|
||||
row('Unsafe shutdowns', formatInteger(disk.smart?.unsafeShutdowns)),
|
||||
row('Available spare', formatPercent(disk.smart?.availableSpare)),
|
||||
row('Percentage used', formatPercent(disk.smart?.percentageUsed)),
|
||||
]);
|
||||
|
||||
const riskRows = compactRows([
|
||||
row('Risk', normalizeDelimitedLabel(disk.risk?.level), {
|
||||
tone: disk.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
row('Reasons', summarizeList(riskReasons, 2), {
|
||||
title: riskReasons.join(', '),
|
||||
tone: 'warning',
|
||||
}),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
{ label: 'Disk', rows: identityRows },
|
||||
{ label: 'Health', rows: healthRows },
|
||||
{ label: 'SMART', rows: smartRows },
|
||||
{ label: 'Risk', rows: riskRows },
|
||||
]);
|
||||
};
|
||||
|
||||
const portLabel = (port: ResourceTrueNASAppPort): string | null => {
|
||||
const protocol = asString(port.protocol)?.toLowerCase();
|
||||
const container =
|
||||
@@ -341,6 +546,12 @@ export const buildTrueNASDetailSections = (
|
||||
if (resource.truenas?.share) return buildTrueNASShareSections(resource.truenas.share);
|
||||
if (resource.truenas?.vm) return buildTrueNASVMSections(resource.truenas.vm);
|
||||
if (resource.truenas?.app) return buildTrueNASAppSections(resource.truenas.app);
|
||||
if (isTrueNASScopedResource(resource) && resource.storage) {
|
||||
return buildTrueNASStorageSections(resource, resource.storage);
|
||||
}
|
||||
if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
|
||||
return buildTrueNASDiskSections(resource, resource.physicalDisk);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -381,5 +592,30 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
|
||||
return summary.length > 0 ? summary.join(', ') : null;
|
||||
}
|
||||
|
||||
if (isTrueNASScopedResource(resource) && resource.storage) {
|
||||
const storage = resource.storage;
|
||||
const summary = [
|
||||
storageKindLabel(storage),
|
||||
storageStateLabel(resource, storage),
|
||||
storageUsageLabel(resource),
|
||||
asString(storage.riskSummary),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return summary.length > 0 ? summary.join(', ') : null;
|
||||
}
|
||||
|
||||
if (isTrueNASScopedResource(resource) && resource.physicalDisk) {
|
||||
const disk = resource.physicalDisk;
|
||||
const summary = [
|
||||
diskTypeLabel(disk.diskType),
|
||||
normalizeDelimitedLabel(disk.health),
|
||||
formatBytes(disk.sizeBytes),
|
||||
formatTemperature(disk.temperature),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return summary.length > 0 ? summary.join(', ') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const hasTrueNASDetailSections = (resource: Resource): boolean =>
|
||||
buildTrueNASDetailSections(resource).length > 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, createSignal, type Component, type JSX } from 'solid-js';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { hasTrueNASDetailSections } from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
|
||||
@@ -63,9 +64,6 @@ export function createPlatformResourceLabelResolver(resources: () => readonly Re
|
||||
};
|
||||
}
|
||||
|
||||
const hasNativeTrueNASDetails = (resource: Resource): boolean =>
|
||||
Boolean(resource.truenas?.app || resource.truenas?.share || resource.truenas?.vm);
|
||||
|
||||
export const PlatformResourceDetailTableRow: Component<{
|
||||
resource: Resource;
|
||||
open: boolean;
|
||||
@@ -76,7 +74,7 @@ export const PlatformResourceDetailTableRow: Component<{
|
||||
onClose?: () => void;
|
||||
}> = (props) => {
|
||||
const initialShowTrueNASDetails = () =>
|
||||
props.initialShowTrueNASDetails ?? hasNativeTrueNASDetails(props.resource);
|
||||
props.initialShowTrueNASDetails ?? hasTrueNASDetailSections(props.resource);
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
|
||||
+44
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { KubernetesClustersTable } from '@/features/kubernetes/KubernetesClustersTable';
|
||||
import { KubernetesNodesTable } from '@/features/kubernetes/KubernetesNodesTable';
|
||||
import { TrueNASStorageTopologyTable } from '@/features/truenas/TrueNASStorageTopologyTable';
|
||||
import { TrueNASSystemsTable } from '@/features/truenas/TrueNASSystemsTable';
|
||||
import { VsphereHostsTable } from '@/features/vmware/VsphereHostsTable';
|
||||
|
||||
@@ -11,12 +12,14 @@ vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
|
||||
ResourceDetailDrawer: (props: {
|
||||
resource: { id: string };
|
||||
presentation?: string;
|
||||
initialShowTrueNASDetails?: boolean;
|
||||
onClose?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="resource-detail-drawer"
|
||||
data-resource-id={props.resource.id}
|
||||
data-presentation={props.presentation ?? 'full'}
|
||||
data-initial-show-truenas-details={String(props.initialShowTrueNASDetails ?? false)}
|
||||
>
|
||||
<button type="button" aria-label="Close resource drawer" onClick={() => props.onClose?.()} />
|
||||
</div>
|
||||
@@ -103,6 +106,47 @@ describe('platform resource table drawers', () => {
|
||||
await expectRowOpensResourceDrawer(row!, system.id);
|
||||
});
|
||||
|
||||
it('opens native TrueNAS detail by default from TrueNAS storage topology rows', async () => {
|
||||
const pool = makeResource({
|
||||
id: 'storage:tank',
|
||||
type: 'storage',
|
||||
name: 'tank',
|
||||
displayName: 'tank',
|
||||
platformType: 'truenas',
|
||||
platformScopes: ['truenas'],
|
||||
storage: {
|
||||
topology: 'pool',
|
||||
platform: 'truenas',
|
||||
zfsPoolState: 'ONLINE',
|
||||
},
|
||||
});
|
||||
|
||||
render(() => (
|
||||
<TrueNASStorageTopologyTable
|
||||
resources={[pool]}
|
||||
scope={[pool]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No storage"
|
||||
emptyDescription="No storage"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const row = screen.getByText('tank').closest('tr');
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await fireEvent.click(row!);
|
||||
|
||||
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
|
||||
'data-resource-id',
|
||||
pool.id,
|
||||
);
|
||||
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
|
||||
'data-initial-show-truenas-details',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens canonical resource details from vSphere host rows', async () => {
|
||||
const host = makeResource({
|
||||
id: 'vmware-host:esxi-01',
|
||||
|
||||
@@ -909,6 +909,7 @@ export interface Resource {
|
||||
// Hierarchy
|
||||
parentId?: string; // Parent resource (e.g., VM -> Node)
|
||||
parentName?: string; // Parent display identifier when available
|
||||
childCount?: number; // Backend-derived count of direct child resources
|
||||
clusterId?: string; // Cluster membership
|
||||
|
||||
// Universal Metrics
|
||||
|
||||
Reference in New Issue
Block a user