mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
Expose native TrueNAS details in resource drawer
This commit is contained in:
@@ -72,6 +72,19 @@ const vmwareRowToneClass = (tone?: 'default' | 'accent' | 'warning'): string =>
|
||||
}
|
||||
};
|
||||
|
||||
const trueNASRowToneClass = (tone?: 'default' | 'accent' | 'warning' | 'success'): string => {
|
||||
switch (tone) {
|
||||
case 'accent':
|
||||
return 'text-cyan-700 dark:text-cyan-300';
|
||||
case 'warning':
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
case 'success':
|
||||
return 'text-emerald-700 dark:text-emerald-300';
|
||||
default:
|
||||
return 'text-base-content';
|
||||
}
|
||||
};
|
||||
|
||||
const pbsEvidenceBadgeClass = (tone: string): string => {
|
||||
switch (tone) {
|
||||
case 'danger':
|
||||
@@ -622,6 +635,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
when={
|
||||
drawer.hasServiceDetails() ||
|
||||
drawer.hasVMwareDetails() ||
|
||||
drawer.hasTrueNASDetails() ||
|
||||
drawer.hasHostDetails() ||
|
||||
drawer.hasAccessContext() ||
|
||||
drawer.hasInvestigationContext()
|
||||
@@ -631,6 +645,47 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
data-testid="resource-support-sections"
|
||||
class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(50%-0.375rem)] [&>*]:min-w-[260px] [&>*]:max-w-full [&>*]:overflow-hidden"
|
||||
>
|
||||
<Show when={drawer.hasTrueNASDetails()}>
|
||||
<SupportDisclosure
|
||||
title="TrueNAS"
|
||||
summary={drawer.trueNASDetailsSummary()}
|
||||
expanded={drawer.showTrueNASDetails()}
|
||||
onToggle={() => drawer.setShowTrueNASDetails((value) => !value)}
|
||||
showLabel="Show TrueNAS"
|
||||
hideLabel="Hide TrueNAS"
|
||||
class="h-full"
|
||||
contentClass="mt-3 space-y-3"
|
||||
dataTestId="resource-truenas-details-section"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={drawer.trueNASDetailSections()}>
|
||||
{(section) => (
|
||||
<div class="rounded border border-cyan-200 bg-cyan-50 p-3 dark:border-cyan-700 dark:bg-cyan-900">
|
||||
<div class="mb-2 text-[10px] font-medium uppercase tracking-wide text-cyan-700 dark:text-cyan-300">
|
||||
{section.label}
|
||||
</div>
|
||||
<div class="space-y-1.5 text-[11px]">
|
||||
<For each={section.rows}>
|
||||
{(row) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted">{row.label}</span>
|
||||
<span
|
||||
class={`max-w-[60%] truncate text-right font-medium ${trueNASRowToneClass(row.tone)}`}
|
||||
title={row.title ?? row.value}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SupportDisclosure>
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.hasAccessContext()}>
|
||||
<SupportDisclosure
|
||||
title="Access"
|
||||
|
||||
+11
-3
@@ -15,6 +15,7 @@ import resourceDetailDrawerIdentityModelSource from '@/components/Infrastructure
|
||||
import resourceDetailDrawerOperationalModelSource from '@/components/Infrastructure/resourceDetailDrawerOperationalModel.ts?raw';
|
||||
import resourceDetailDrawerServiceModelSource from '@/components/Infrastructure/resourceDetailDrawerServiceModel.ts?raw';
|
||||
import resourceDetailDrawerVmwareModelSource from '@/components/Infrastructure/resourceDetailDrawerVmwareModel.ts?raw';
|
||||
import resourceDetailDrawerTrueNASModelSource from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts?raw';
|
||||
import resourceDetailDrawerDockerActionsStateSource from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts?raw';
|
||||
import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw';
|
||||
import actionAuditApiSource from '@/api/actionAudit.ts?raw';
|
||||
@@ -194,9 +195,7 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain('resource.relationships ?? []');
|
||||
expect(resourceActionHistorySource).toContain('getActionAuditRecordStatePresentation');
|
||||
expect(resourceActionHistorySource).toContain('getActionAuditResultPresentation');
|
||||
expect(resourceActionHistorySource).toContain(
|
||||
'getActionAuditVerificationOutcomePresentation',
|
||||
);
|
||||
expect(resourceActionHistorySource).toContain('getActionAuditVerificationOutcomePresentation');
|
||||
expect(resourceActionHistorySource).toContain('formatActionApprovalPolicyLabel');
|
||||
// The audit history must surface the broker's read-after-write
|
||||
// verification outcome alongside the dispatch result, not silently
|
||||
@@ -228,6 +227,9 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain(
|
||||
"from './resourceDetailDrawerVmwareModel'",
|
||||
);
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain(
|
||||
"from './resourceDetailDrawerTrueNASModel'",
|
||||
);
|
||||
expect(resourceDetailDrawerDiscoveryModelSource).toContain('export const toDiscoveryConfig');
|
||||
expect(resourceDetailDrawerIdentityModelSource).toContain(
|
||||
'export const buildResourceIdentityView',
|
||||
@@ -244,6 +246,12 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
expect(resourceDetailDrawerVmwareModelSource).toContain(
|
||||
'export const buildVMwareDetailsSummary',
|
||||
);
|
||||
expect(resourceDetailDrawerTrueNASModelSource).toContain(
|
||||
'export const buildTrueNASDetailSections',
|
||||
);
|
||||
expect(resourceDetailDrawerTrueNASModelSource).toContain(
|
||||
'export const buildTrueNASDetailsSummary',
|
||||
);
|
||||
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildWorkloadsHref');
|
||||
expect(resourceDetailDrawerDerivedStateSource).not.toContain('buildServiceDetailLinks');
|
||||
expect(resourceDetailDrawerDerivedStateSource).not.toContain('const supportedBadge =');
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, within } from '@solidjs/testing-library';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
state: { pmg: [] as any[] },
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
reconnecting: () => false,
|
||||
reconnect: vi.fn(),
|
||||
}),
|
||||
useDarkMode: () => () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Discovery/DiscoveryTab', () => ({
|
||||
DiscoveryTab: () => <div data-testid="discovery-tab" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/resources', () => ({
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn().mockResolvedValue({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
AIAPI: {
|
||||
getResourceIntelligence: vi.fn().mockResolvedValue({
|
||||
resource_id: 'resource-1',
|
||||
health: {
|
||||
score: 100,
|
||||
grade: 'A',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'Stable',
|
||||
},
|
||||
recent_changes: [],
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
correlations: [],
|
||||
note_count: 0,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const baseResource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'vm',
|
||||
name: 'resource-1',
|
||||
displayName: 'resource-1',
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
platformData: { sources: ['truenas'] },
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
it('renders native TrueNAS VM detail only after the TrueNAS section is expanded', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-vm-1',
|
||||
type: 'vm',
|
||||
displayName: 'ubuntu-build',
|
||||
truenas: {
|
||||
vm: {
|
||||
name: 'ubuntu-build',
|
||||
state: 'RUNNING',
|
||||
vcpus: 4,
|
||||
memoryBytes: 8 * 1024 ** 3,
|
||||
deviceCount: 3,
|
||||
diskCount: 1,
|
||||
nicCount: 1,
|
||||
bootloader: 'UEFI',
|
||||
secureBoot: true,
|
||||
trustedPlatformModule: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getByRole, getByTestId, getAllByText, queryByText } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
|
||||
expect(getAllByText('TrueNAS').length).toBeGreaterThan(0);
|
||||
expect(getByText('Running, 4 vCPU, 8.00 GB, 3 devices')).toBeInTheDocument();
|
||||
expect(queryByText('Bootloader')).toBeNull();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show TrueNAS' }));
|
||||
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(section.getByText('Compute')).toBeInTheDocument();
|
||||
expect(section.getByText('Runtime')).toBeInTheDocument();
|
||||
expect(section.getByText('Devices')).toBeInTheDocument();
|
||||
expect(section.getByText('Flags')).toBeInTheDocument();
|
||||
expect(section.getByText('Bootloader')).toBeInTheDocument();
|
||||
expect(section.getByText('UEFI')).toBeInTheDocument();
|
||||
expect(section.getByText('Secure boot')).toBeInTheDocument();
|
||||
expect(section.getAllByText('Enabled').length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('renders native TrueNAS app detail without Docker runtime controls', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-app-1',
|
||||
type: 'app-container',
|
||||
displayName: 'nextcloud',
|
||||
truenas: {
|
||||
app: {
|
||||
name: 'nextcloud',
|
||||
state: 'RUNNING',
|
||||
humanVersion: '29.0.0',
|
||||
containerCount: 2,
|
||||
upgradeAvailable: true,
|
||||
imageUpdatesAvailable: false,
|
||||
usedHostIps: ['0.0.0.0'],
|
||||
usedPorts: [
|
||||
{
|
||||
containerPort: 443,
|
||||
protocol: 'tcp',
|
||||
hostPorts: [{ hostIp: '0.0.0.0', hostPort: 30443 }],
|
||||
},
|
||||
],
|
||||
networks: [{ name: 'ix-nextcloud_default' }],
|
||||
volumes: [{ source: '/mnt/tank/apps/nextcloud', destination: '/data' }],
|
||||
images: ['nextcloud:29'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getByRole, getByTestId, queryByText } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
|
||||
expect(getByText('Running, 2 containers, 1 port, 1 update')).toBeInTheDocument();
|
||||
expect(queryByText('Docker runtime')).toBeNull();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show TrueNAS' }));
|
||||
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(section.getByText('App')).toBeInTheDocument();
|
||||
expect(section.getByText('Networking')).toBeInTheDocument();
|
||||
expect(section.getByText('Storage')).toBeInTheDocument();
|
||||
expect(section.getByText('0.0.0.0:30443 -> 443/tcp')).toBeInTheDocument();
|
||||
expect(section.getByText('/mnt/tank/apps/nextcloud -> /data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildTrueNASDetailSections,
|
||||
buildTrueNASDetailsSummary,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const baseResource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
id: 'truenas-resource',
|
||||
type: 'vm',
|
||||
name: 'truenas-resource',
|
||||
displayName: 'TrueNAS resource',
|
||||
platformId: 'truenas-main',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
it('summarizes native vm.query metadata for the detail drawer', () => {
|
||||
const resource = baseResource({
|
||||
type: 'vm',
|
||||
truenas: {
|
||||
vm: {
|
||||
name: 'ubuntu-build',
|
||||
state: 'RUNNING',
|
||||
vcpus: 4,
|
||||
memoryBytes: 8 * 1024 ** 3,
|
||||
deviceCount: 3,
|
||||
diskCount: 1,
|
||||
nicCount: 1,
|
||||
bootloader: 'UEFI',
|
||||
secureBoot: true,
|
||||
trustedPlatformModule: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildTrueNASDetailsSummary(resource)).toBe('Running, 4 vCPU, 8.00 GB, 3 devices');
|
||||
expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
|
||||
'Compute',
|
||||
'Runtime',
|
||||
'Devices',
|
||||
'Flags',
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes native app.query metadata for the detail drawer', () => {
|
||||
const resource = baseResource({
|
||||
type: 'app-container',
|
||||
truenas: {
|
||||
app: {
|
||||
name: 'nextcloud',
|
||||
state: 'RUNNING',
|
||||
humanVersion: '29.0.0',
|
||||
containerCount: 2,
|
||||
upgradeAvailable: true,
|
||||
imageUpdatesAvailable: false,
|
||||
usedHostIps: ['0.0.0.0'],
|
||||
usedPorts: [
|
||||
{
|
||||
containerPort: 443,
|
||||
protocol: 'tcp',
|
||||
hostPorts: [{ hostIp: '0.0.0.0', hostPort: 30443 }],
|
||||
},
|
||||
{
|
||||
containerPort: 80,
|
||||
protocol: 'tcp',
|
||||
hostPorts: [{ hostIp: '0.0.0.0', hostPort: 30080 }],
|
||||
},
|
||||
],
|
||||
volumes: [{ source: '/mnt/tank/apps/nextcloud', destination: '/data' }],
|
||||
images: ['nextcloud:29'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildTrueNASDetailsSummary(resource)).toBe('Running, 2 containers, 2 ports, 1 update');
|
||||
expect(buildTrueNASDetailSections(resource).map((section) => section.label)).toEqual([
|
||||
'App',
|
||||
'Networking',
|
||||
'Storage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import type {
|
||||
Resource,
|
||||
ResourceTrueNASAppMeta,
|
||||
ResourceTrueNASAppPort,
|
||||
ResourceTrueNASVMMeta,
|
||||
} from '@/types/resource';
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRowTone = 'default' | 'accent' | 'warning' | 'success';
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRow = {
|
||||
label: string;
|
||||
value: string;
|
||||
title?: string;
|
||||
tone?: ResourceDetailDrawerTrueNASRowTone;
|
||||
};
|
||||
|
||||
export type ResourceDetailDrawerTrueNASSection = {
|
||||
label: string;
|
||||
rows: ResourceDetailDrawerTrueNASRow[];
|
||||
};
|
||||
|
||||
const asString = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const asPositiveNumber = (value?: number): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
|
||||
|
||||
const formatInteger = (value?: number): string | null => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return new Intl.NumberFormat().format(Math.round(value));
|
||||
};
|
||||
|
||||
const normalizeDelimitedLabel = (value?: string): string | null => {
|
||||
const trimmed = asString(value);
|
||||
if (!trimmed) return null;
|
||||
return trimmed
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number): string | null => {
|
||||
const value = asPositiveNumber(bytes);
|
||||
if (!value) return null;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let scaled = value;
|
||||
let unitIndex = 0;
|
||||
while (scaled >= 1024 && unitIndex < units.length - 1) {
|
||||
scaled /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatCount = (value: number, singular: string, plural = `${singular}s`): string =>
|
||||
`${new Intl.NumberFormat().format(value)} ${value === 1 ? singular : plural}`;
|
||||
|
||||
const summarizeList = (
|
||||
values: string[],
|
||||
visibleCount = 3,
|
||||
): ResourceDetailDrawerTrueNASRow['value'] => {
|
||||
const visible = values.map((value) => value.trim()).filter(Boolean);
|
||||
if (visible.length === 0) return '';
|
||||
const head = visible.slice(0, visibleCount);
|
||||
const suffix = visible.length > head.length ? ` +${visible.length - head.length}` : '';
|
||||
return `${head.join(', ')}${suffix}`;
|
||||
};
|
||||
|
||||
const booleanValue = (value?: boolean): string | null => {
|
||||
if (value === undefined) return null;
|
||||
return value ? 'Enabled' : 'Disabled';
|
||||
};
|
||||
|
||||
const yesNoValue = (value?: boolean): string | null => {
|
||||
if (value === undefined) return null;
|
||||
return value ? 'Yes' : 'No';
|
||||
};
|
||||
|
||||
const row = (
|
||||
label: string,
|
||||
value: string | null | undefined,
|
||||
options: Pick<ResourceDetailDrawerTrueNASRow, 'title' | 'tone'> = {},
|
||||
): ResourceDetailDrawerTrueNASRow | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
return { label, value: trimmed, ...options };
|
||||
};
|
||||
|
||||
const compactRows = (
|
||||
rows: Array<ResourceDetailDrawerTrueNASRow | null>,
|
||||
): ResourceDetailDrawerTrueNASRow[] =>
|
||||
rows.filter((entry): entry is ResourceDetailDrawerTrueNASRow => Boolean(entry));
|
||||
|
||||
const compactSections = (
|
||||
sections: Array<ResourceDetailDrawerTrueNASSection | null>,
|
||||
): ResourceDetailDrawerTrueNASSection[] =>
|
||||
sections.filter((section): section is ResourceDetailDrawerTrueNASSection =>
|
||||
Boolean(section && section.rows.length > 0),
|
||||
);
|
||||
|
||||
const portLabel = (port: ResourceTrueNASAppPort): string | null => {
|
||||
const protocol = asString(port.protocol)?.toLowerCase();
|
||||
const container =
|
||||
asPositiveNumber(port.containerPort) !== null
|
||||
? `${port.containerPort}${protocol ? `/${protocol}` : ''}`
|
||||
: protocol;
|
||||
const hostPorts = (port.hostPorts ?? [])
|
||||
.map((hostPort) => {
|
||||
const portNumber = asPositiveNumber(hostPort.hostPort);
|
||||
if (!portNumber) return null;
|
||||
const hostIp = asString(hostPort.hostIp);
|
||||
return hostIp ? `${hostIp}:${portNumber}` : `${portNumber}`;
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
if (hostPorts.length > 0 && container) return `${hostPorts.join(', ')} -> ${container}`;
|
||||
if (hostPorts.length > 0) return hostPorts.join(', ');
|
||||
return container ?? null;
|
||||
};
|
||||
|
||||
const appPortLabels = (app: ResourceTrueNASAppMeta): string[] =>
|
||||
(app.usedPorts ?? []).map(portLabel).filter((value): value is string => Boolean(value));
|
||||
|
||||
const appVolumeLabels = (app: ResourceTrueNASAppMeta): string[] =>
|
||||
(app.volumes ?? [])
|
||||
.map((volume) => {
|
||||
const source = asString(volume.source);
|
||||
const destination = asString(volume.destination);
|
||||
if (source && destination) return `${source} -> ${destination}`;
|
||||
return destination ?? source;
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const appNetworkLabels = (app: ResourceTrueNASAppMeta): string[] =>
|
||||
(app.networks ?? [])
|
||||
.map((network) => asString(network.name) ?? asString(network.id))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const buildTrueNASAppSections = (
|
||||
app: ResourceTrueNASAppMeta,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
const containerCount = app.containerCount ?? app.containers?.length;
|
||||
const portLabels = appPortLabels(app);
|
||||
const volumeLabels = appVolumeLabels(app);
|
||||
const imageLabels = (app.images ?? []).map((image) => image.trim()).filter(Boolean);
|
||||
const networkLabels = appNetworkLabels(app);
|
||||
const appRows = compactRows([
|
||||
row('State', normalizeDelimitedLabel(app.state), {
|
||||
tone: app.state?.toLowerCase() === 'running' ? 'success' : 'warning',
|
||||
}),
|
||||
row('Version', asString(app.humanVersion) ?? asString(app.version)),
|
||||
row('Containers', formatInteger(containerCount)),
|
||||
row('Custom app', yesNoValue(app.customApp)),
|
||||
row(
|
||||
'App updates',
|
||||
app.upgradeAvailable === undefined ? null : app.upgradeAvailable ? 'Available' : 'Current',
|
||||
{
|
||||
tone: app.upgradeAvailable ? 'warning' : 'success',
|
||||
},
|
||||
),
|
||||
row(
|
||||
'Image updates',
|
||||
app.imageUpdatesAvailable === undefined
|
||||
? null
|
||||
: app.imageUpdatesAvailable
|
||||
? 'Available'
|
||||
: 'Current',
|
||||
{ tone: app.imageUpdatesAvailable ? 'warning' : 'success' },
|
||||
),
|
||||
row('Notes', asString(app.notes)),
|
||||
]);
|
||||
|
||||
const networkRows = compactRows([
|
||||
row('Host IPs', summarizeList(app.usedHostIps ?? []), {
|
||||
title: (app.usedHostIps ?? []).join(', '),
|
||||
}),
|
||||
row('Ports', summarizeList(portLabels), { title: portLabels.join(', ') }),
|
||||
row('Networks', summarizeList(networkLabels), { title: networkLabels.join(', ') }),
|
||||
]);
|
||||
|
||||
const storageRows = compactRows([
|
||||
row('Volumes', summarizeList(volumeLabels), { title: volumeLabels.join(', ') }),
|
||||
row('Images', summarizeList(imageLabels, 2), { title: imageLabels.join(', ') }),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
{ label: 'App', rows: appRows },
|
||||
{ label: 'Networking', rows: networkRows },
|
||||
{ label: 'Storage', rows: storageRows },
|
||||
]);
|
||||
};
|
||||
|
||||
const formatVMCpu = (vm: ResourceTrueNASVMMeta): string | null => {
|
||||
const vcpus = asPositiveNumber(vm.vcpus);
|
||||
if (vcpus) return formatCount(vcpus, 'vCPU', 'vCPU');
|
||||
const cores = asPositiveNumber(vm.cores);
|
||||
const threads = asPositiveNumber(vm.threads);
|
||||
if (cores && threads) return `${cores} cores x ${threads} threads`;
|
||||
if (cores) return formatCount(cores, 'core');
|
||||
if (threads) return formatCount(threads, 'thread');
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatVMTopology = (vm: ResourceTrueNASVMMeta): string | null => {
|
||||
const cores = asPositiveNumber(vm.cores);
|
||||
const threads = asPositiveNumber(vm.threads);
|
||||
if (cores && threads) return `${cores} cores x ${threads} threads`;
|
||||
if (cores) return formatCount(cores, 'core');
|
||||
if (threads) return formatCount(threads, 'thread');
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildTrueNASVMSections = (
|
||||
vm: ResourceTrueNASVMMeta,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
const state = normalizeDelimitedLabel(vm.state);
|
||||
const domainState = normalizeDelimitedLabel(vm.domainState);
|
||||
const sameState = state?.toLowerCase() === domainState?.toLowerCase();
|
||||
const machine = [asString(vm.machineType), asString(vm.archType)].filter(Boolean).join(' / ');
|
||||
const computeRows = compactRows([
|
||||
row('State', state ?? domainState, {
|
||||
tone: (vm.state ?? vm.domainState)?.toLowerCase() === 'running' ? 'success' : 'warning',
|
||||
}),
|
||||
row('Domain state', sameState ? null : domainState),
|
||||
row('vCPU', formatVMCpu(vm)),
|
||||
row('Topology', formatVMTopology(vm)),
|
||||
row('Memory', formatBytes(vm.memoryBytes)),
|
||||
row('Minimum memory', formatBytes(vm.minMemoryBytes)),
|
||||
row('CPU mode', normalizeDelimitedLabel(vm.cpuMode)),
|
||||
row('CPU model', asString(vm.cpuModel)),
|
||||
]);
|
||||
|
||||
const runtimeRows = compactRows([
|
||||
row('Bootloader', asString(vm.bootloader)),
|
||||
row('Machine', machine),
|
||||
row('Process ID', formatInteger(vm.pid)),
|
||||
row('UUID', asString(vm.uuid)),
|
||||
]);
|
||||
|
||||
const deviceRows = compactRows([
|
||||
row('Total', formatInteger(vm.deviceCount)),
|
||||
row('Disks', formatInteger(vm.diskCount)),
|
||||
row('NICs', formatInteger(vm.nicCount)),
|
||||
row('Displays', formatInteger(vm.displayCount)),
|
||||
row('CD-ROMs', formatInteger(vm.cdromCount)),
|
||||
row('USB', formatInteger(vm.usbCount)),
|
||||
row('PCI', formatInteger(vm.pciCount)),
|
||||
]);
|
||||
|
||||
const flagRows = compactRows([
|
||||
row('Autostart', booleanValue(vm.autostart)),
|
||||
row('Secure boot', booleanValue(vm.secureBoot)),
|
||||
row('TPM', booleanValue(vm.trustedPlatformModule)),
|
||||
row('Suspend on snapshot', booleanValue(vm.suspendOnSnapshot)),
|
||||
row('Display available', booleanValue(vm.displayAvailable)),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
{ label: 'Compute', rows: computeRows },
|
||||
{ label: 'Runtime', rows: runtimeRows },
|
||||
{ label: 'Devices', rows: deviceRows },
|
||||
{ label: 'Flags', rows: flagRows },
|
||||
]);
|
||||
};
|
||||
|
||||
export const buildTrueNASDetailSections = (
|
||||
resource: Resource,
|
||||
): ResourceDetailDrawerTrueNASSection[] => {
|
||||
if (resource.truenas?.vm) return buildTrueNASVMSections(resource.truenas.vm);
|
||||
if (resource.truenas?.app) return buildTrueNASAppSections(resource.truenas.app);
|
||||
return [];
|
||||
};
|
||||
|
||||
export const buildTrueNASDetailsSummary = (resource: Resource): string | null => {
|
||||
const vm = resource.truenas?.vm;
|
||||
if (vm) {
|
||||
const deviceCount = asPositiveNumber(vm.deviceCount);
|
||||
const summary = [
|
||||
normalizeDelimitedLabel(vm.state ?? vm.domainState),
|
||||
formatVMCpu(vm),
|
||||
formatBytes(vm.memoryBytes),
|
||||
deviceCount ? formatCount(deviceCount, 'device') : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return summary.length > 0 ? summary.join(', ') : null;
|
||||
}
|
||||
|
||||
const app = resource.truenas?.app;
|
||||
if (app) {
|
||||
const portCount = appPortLabels(app).length;
|
||||
const updateCount = [app.upgradeAvailable, app.imageUpdatesAvailable].filter(Boolean).length;
|
||||
const summary = [
|
||||
normalizeDelimitedLabel(app.state),
|
||||
formatCount(app.containerCount ?? app.containers?.length ?? 0, 'container'),
|
||||
formatCount(portCount, 'port'),
|
||||
updateCount > 0 ? formatCount(updateCount, 'update') : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return summary.length > 0 ? summary.join(', ') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -69,6 +69,10 @@ import {
|
||||
buildVMwareDetailsSummary,
|
||||
buildVMwareDetailSections,
|
||||
} from './resourceDetailDrawerVmwareModel';
|
||||
import {
|
||||
buildTrueNASDetailsSummary,
|
||||
buildTrueNASDetailSections,
|
||||
} from './resourceDetailDrawerTrueNASModel';
|
||||
import { getResourceHealthIssuePresentation } from './resourceHealthPresentation';
|
||||
|
||||
type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
|
||||
@@ -319,6 +323,9 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
const vmwareDetailsSummary = createMemo(() =>
|
||||
buildVMwareDetailsSummary(resource.type, vmwareData()),
|
||||
);
|
||||
const trueNASDetailSections = createMemo(() => buildTrueNASDetailSections(resource));
|
||||
const hasTrueNASDetails = createMemo(() => trueNASDetailSections().length > 0);
|
||||
const trueNASDetailsSummary = createMemo(() => buildTrueNASDetailsSummary(resource));
|
||||
|
||||
const relatedLinks = createMemo(() => buildRelatedLinks(resource, displayName()));
|
||||
const accessSummary = createMemo(() =>
|
||||
@@ -444,6 +451,9 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
hasVMwareDetails,
|
||||
vmwareDetailsSummary,
|
||||
vmwareDetailSections,
|
||||
hasTrueNASDetails,
|
||||
trueNASDetailsSummary,
|
||||
trueNASDetailSections,
|
||||
relatedLinks,
|
||||
hasRuntimeOperationalContext,
|
||||
sourceSections,
|
||||
|
||||
@@ -27,6 +27,7 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
const [showHostDetails, setShowHostDetails] = createSignal(false);
|
||||
const [showServiceDetails, setShowServiceDetails] = createSignal(false);
|
||||
const [showVMwareDetails, setShowVMwareDetails] = createSignal(false);
|
||||
const [showTrueNASDetails, setShowTrueNASDetails] = createSignal(false);
|
||||
const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false);
|
||||
const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false);
|
||||
const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal('');
|
||||
@@ -105,6 +106,8 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta
|
||||
setShowServiceDetails,
|
||||
showVMwareDetails,
|
||||
setShowVMwareDetails,
|
||||
showTrueNASDetails,
|
||||
setShowTrueNASDetails,
|
||||
showPbsJobDetail,
|
||||
setShowPbsJobDetail,
|
||||
showPmgMailFlowDetail,
|
||||
|
||||
Reference in New Issue
Block a user