mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
truenas: bespoke Systems + Disks tables; project uptime/temp on the system row
Audit pass on the TrueNAS pages turned up three concrete issues: 1. **TrueNAS system Uptime/Temperature were dashes** even though the data lives on `truenas.uptimeSeconds` and on the `agent.temperature` max-sensor projection. The system row is emitted by `internal/truenas/provider.go::truenasRecordsFromSnapshot`, which is a separate path from the host/node adapters that the prior commit (c7bdd11e0) updated. Project the same top-level `Resource.Uptime` and `Resource.Temperature` here so the canonical table renders real values (live mock now reports `uptime: 3628800, temperature: 61.5` on the TrueNAS appliance row). 2. **The Systems tab needed NAS-native columns.** A TrueNAS appliance is a storage box — operators want at-a-glance pool / dataset / disk / app counts on the same row as CPU / Memory / Storage / Temperature / Uptime. New `TrueNASSystemsTable` reuses canonical shared primitives (Card, Table, SearchInput, FilterButtonGroup, StatusDot) and computes per-system child counts client-side from the same TrueNAS scope already fetched by the page. 3. **Physical disks weren't surfaced anywhere on the page**, even though the canonical TrueNAS adapter emits SMART-instrumented `physical_disk` rows with model / serial / size / health / temperature / wearout. Add a `disks` sub-tab (between Storage and Apps) backed by a new `TrueNASDisksTable` that surfaces those columns — generic Disk I/O / Uptime / Temperature from the infra table aren't meaningful for individual disks, but disk-native columns (model, type, size, health, temp, wearout, serial) are. Browser verification (Playwright, chromium, live mock-mode dev runtime): - 9 tests pass. The every-sub-tab operator-controls audit now covers /truenas/disks too; the search input on every TrueNAS sub-tab is the canonical SearchInput primitive. Targeted vitest: `src/features/truenas/__tests__/truenasPageModel.test.ts` (2 tests) green — covers the four-tab spec and the new `disks` bucket on the page model. Targeted Go: `go test ./internal/truenas/...` green. Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set. This completes the platform-page column-fit audit for TrueNAS. New top-level `Resource.Uptime` / `Resource.Temperature` projection is strictly additive (already documented as the adapter responsibility in unified-resources.md byc7bdd11e0). The two new bespoke tables live inside features/truenas/ and reuse canonical primitives only.
This commit is contained in:
parent
c7bdd11e04
commit
5b94724bf2
7 changed files with 480 additions and 16 deletions
201
frontend-modern/src/features/truenas/TrueNASDisksTable.tsx
Normal file
201
frontend-modern/src/features/truenas/TrueNASDisksTable.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
filterPlatformResources,
|
||||
type PlatformResourceStatusFilter,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
// TrueNAS physical disks are SMART-instrumented storage devices, not
|
||||
// compute hosts. The canonical infrastructure table's CPU / Memory /
|
||||
// Disk I/O / Uptime columns are conceptually N/A; the operator columns
|
||||
// are model, serial, type, size, health, temperature, and wearout.
|
||||
// This bespoke table surfaces those from the canonical `physicalDisk`
|
||||
// payload already attached to each row.
|
||||
|
||||
const STATUS_FILTER_OPTIONS: FilterOption<PlatformResourceStatusFilter>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'online', label: 'Healthy' },
|
||||
{ value: 'degraded', label: 'Degraded' },
|
||||
{ value: 'offline', label: 'Offline' },
|
||||
];
|
||||
|
||||
const formatBytes = (bytes: number | undefined): string => {
|
||||
if (!bytes || bytes <= 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1024 && unitIdx < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIdx += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`;
|
||||
};
|
||||
|
||||
const formatTemperature = (celsius: number | undefined): JSX.Element => {
|
||||
if (typeof celsius !== 'number' || celsius <= 0) return <span class="text-muted">—</span>;
|
||||
return <span class="tabular-nums">{celsius.toFixed(1)}°C</span>;
|
||||
};
|
||||
|
||||
const formatWearout = (wearout: number | undefined): JSX.Element => {
|
||||
if (typeof wearout !== 'number' || wearout < 0) return <span class="text-muted">—</span>;
|
||||
return <span class="tabular-nums">{wearout.toFixed(0)}%</span>;
|
||||
};
|
||||
|
||||
const healthVariant = (
|
||||
health: string | undefined,
|
||||
): 'success' | 'warning' | 'danger' | 'muted' => {
|
||||
const normalized = (health || '').trim().toLowerCase();
|
||||
if (normalized === 'healthy' || normalized === 'passed' || normalized === 'pass') return 'success';
|
||||
if (normalized === 'warning' || normalized === 'degraded') return 'warning';
|
||||
if (normalized === 'failed' || normalized === 'fail' || normalized === 'critical') return 'danger';
|
||||
return 'muted';
|
||||
};
|
||||
|
||||
export const TrueNASDisksTable: Component<{
|
||||
resources: Resource[];
|
||||
emptyIcon: JSX.Element;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
}> = (props) => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
|
||||
|
||||
const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status()));
|
||||
const visible = createMemo(() => filtered().length);
|
||||
const total = createMemo(() => props.resources.length);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.resources.length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search disks"
|
||||
/>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
value={status()}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show when={visible() !== total()} fallback={<>{total()} disks</>}>
|
||||
{visible()} of {total()} disks
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="No disks match current filters"
|
||||
description="Adjust the search or status filter to see more disks."
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[880px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Disk</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Model</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Type</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Health</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Wearout</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Serial</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<For each={filtered()}>
|
||||
{(disk) => {
|
||||
const meta = () => disk.physicalDisk;
|
||||
const name = () => asTrimmedString(disk.name) || disk.id;
|
||||
const model = () => asTrimmedString(meta()?.model) || '—';
|
||||
const type = () => asTrimmedString(meta()?.diskType) || '—';
|
||||
const health = () => asTrimmedString(meta()?.health) || '—';
|
||||
const serial = () => asTrimmedString(meta()?.serial) || '—';
|
||||
return (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2">
|
||||
<span class="font-semibold text-base-content truncate" title={name()}>
|
||||
{name()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<span class="truncate inline-block max-w-[14rem]" title={model()}>
|
||||
{model()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{type()}</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatBytes(meta()?.sizeBytes)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={healthVariant(meta()?.health)}
|
||||
title={meta()?.health || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="text-base-content">{health()}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatTemperature(meta()?.temperature)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatWearout(meta()?.wearout)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<span class="truncate inline-block max-w-[10rem]" title={serial()}>
|
||||
{serial()}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrueNASDisksTable;
|
||||
|
|
@ -6,10 +6,11 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
|
|||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import {
|
||||
PlatformErrorState,
|
||||
PlatformResourceTable,
|
||||
PlatformSectionTabs,
|
||||
PlatformTableEmptyState,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { TrueNASDisksTable } from './TrueNASDisksTable';
|
||||
import { TrueNASSystemsTable } from './TrueNASSystemsTable';
|
||||
import {
|
||||
TRUENAS_TAB_SPECS,
|
||||
buildTrueNASPageModel,
|
||||
|
|
@ -75,8 +76,9 @@ export function TrueNASPageSurface() {
|
|||
}
|
||||
>
|
||||
<Show when={activeTab() === 'overview'}>
|
||||
<PlatformResourceTable
|
||||
resources={model().systems}
|
||||
<TrueNASSystemsTable
|
||||
systems={model().systems}
|
||||
scope={model().resources}
|
||||
emptyIcon={truenasIcon()}
|
||||
emptyTitle="No TrueNAS systems"
|
||||
emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance."
|
||||
|
|
@ -90,6 +92,14 @@ export function TrueNASPageSurface() {
|
|||
forcedSourceFilter={TRUENAS_PLATFORM_FILTER}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'disks'}>
|
||||
<TrueNASDisksTable
|
||||
resources={model().disks}
|
||||
emptyIcon={truenasIcon()}
|
||||
emptyTitle="No TrueNAS disks reported"
|
||||
emptyDescription="Physical disks appear here once a TrueNAS connection enumerates them."
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'apps'}>
|
||||
<WorkloadsSurface
|
||||
vms={[]}
|
||||
|
|
|
|||
234
frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx
Normal file
234
frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
filterPlatformResources,
|
||||
type PlatformResourceStatusFilter,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
// TrueNAS systems are storage appliances, not generic compute hosts.
|
||||
// The generic infrastructure table's CPU / Memory / Disk columns are
|
||||
// helpful (the agent payload carries them), but operators also want at-
|
||||
// a-glance pool count, dataset count, app count, uptime, version, and
|
||||
// max-sensor temperature on the same row. This bespoke table reuses
|
||||
// canonical shared primitives (Card, Table, SearchInput,
|
||||
// FilterButtonGroup, StatusDot) and counts the per-system children
|
||||
// client-side from the same TrueNAS resource scope already fetched by
|
||||
// the page (no extra API calls).
|
||||
|
||||
const STATUS_FILTER_OPTIONS: FilterOption<PlatformResourceStatusFilter>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'online', label: 'Healthy' },
|
||||
{ value: 'degraded', label: 'Degraded' },
|
||||
{ value: 'offline', label: 'Offline' },
|
||||
];
|
||||
|
||||
const formatUptime = (seconds: number | undefined): string => {
|
||||
if (!seconds || seconds <= 0) return '—';
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
if (days > 0) return `${days}d`;
|
||||
const hours = Math.floor(seconds / 3_600);
|
||||
if (hours > 0) return `${hours}h`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number | undefined): string => {
|
||||
if (!bytes || bytes <= 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1024 && unitIdx < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIdx += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`;
|
||||
};
|
||||
|
||||
const formatPercent = (percent?: number): JSX.Element => {
|
||||
if (typeof percent !== 'number' || Number.isNaN(percent)) return <span class="text-muted">—</span>;
|
||||
return <span class="tabular-nums">{percent.toFixed(1)}%</span>;
|
||||
};
|
||||
|
||||
const formatTemperature = (celsius: number | undefined): JSX.Element => {
|
||||
if (typeof celsius !== 'number' || celsius <= 0) return <span class="text-muted">—</span>;
|
||||
return <span class="tabular-nums">{celsius.toFixed(1)}°C</span>;
|
||||
};
|
||||
|
||||
export const TrueNASSystemsTable: Component<{
|
||||
systems: Resource[];
|
||||
// Full TrueNAS resource scope so we can count pools / datasets / apps
|
||||
// per system without spawning additional fetches.
|
||||
scope: Resource[];
|
||||
emptyIcon: JSX.Element;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
}> = (props) => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
|
||||
|
||||
const filtered = createMemo(() => filterPlatformResources(props.systems, search(), status()));
|
||||
const visible = createMemo(() => filtered().length);
|
||||
const total = createMemo(() => props.systems.length);
|
||||
|
||||
// Single-system canonical mock is the common case today; counts span
|
||||
// the full TrueNAS resource scope per row. When multi-system support
|
||||
// arrives, the canonical adapter should attach a parent system id to
|
||||
// child resources and we can filter by it.
|
||||
const counts = createMemo(() => {
|
||||
const pools = props.scope.filter(
|
||||
(r) => r.type === 'pool' || (r.type === 'storage' && r.storage?.topology === 'pool'),
|
||||
).length;
|
||||
const datasets = props.scope.filter(
|
||||
(r) => r.type === 'dataset' || (r.type === 'storage' && r.storage?.topology === 'dataset'),
|
||||
).length;
|
||||
const apps = props.scope.filter((r) => r.type === 'app-container').length;
|
||||
const disks = props.scope.filter((r) => r.type === 'physical_disk').length;
|
||||
return { pools, datasets, apps, disks };
|
||||
});
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.systems.length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search TrueNAS systems"
|
||||
/>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
value={status()}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show when={visible() !== total()} fallback={<>{total()} systems</>}>
|
||||
{visible()} of {total()} systems
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="No systems match current filters"
|
||||
description="Adjust the search or status filter to see more systems."
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[960px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">System</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">CPU</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Memory</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Storage</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Pools</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Datasets</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Disks</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Apps</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<For each={filtered()}>
|
||||
{(system) => {
|
||||
const name = () => asTrimmedString(system.name) || system.id;
|
||||
const version = () => asTrimmedString(system.agent?.osVersion) || '—';
|
||||
const indicator = () => getSimpleStatusIndicator(system.status);
|
||||
const c = counts();
|
||||
return (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={system.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="font-semibold text-base-content truncate" title={name()}>
|
||||
{name()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
{version()}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatUptime(system.uptime)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatPercent(system.cpu?.current)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatPercent(system.memory?.current)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{typeof system.disk?.used === 'number' && typeof system.disk?.total === 'number'
|
||||
? `${formatBytes(system.disk.used)} / ${formatBytes(system.disk.total)}`
|
||||
: formatPercent(system.disk?.current)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatTemperature(system.temperature)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{c.pools}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{c.datasets}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{c.disks}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{c.apps}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrueNASSystemsTable;
|
||||
|
|
@ -14,11 +14,16 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
|
|||
});
|
||||
|
||||
describe('truenasPageModel', () => {
|
||||
it('declares the TrueNAS section set with Systems, Storage, and Apps', () => {
|
||||
expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual(['overview', 'storage', 'apps']);
|
||||
it('declares the TrueNAS section set with Systems, Storage, Disks, and Apps', () => {
|
||||
expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
'storage',
|
||||
'disks',
|
||||
'apps',
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets systems, apps, storage, and disks while ignoring non-TrueNAS resources', () => {
|
||||
it('buckets systems, disks, apps, storage while ignoring non-TrueNAS resources', () => {
|
||||
const model = buildTrueNASPageModel([
|
||||
makeResource({ id: 'truenas-system', type: 'agent' }),
|
||||
makeResource({ id: 'truenas-app', type: 'app-container' }),
|
||||
|
|
@ -29,6 +34,7 @@ describe('truenasPageModel', () => {
|
|||
]);
|
||||
|
||||
expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']);
|
||||
expect(model.disks.map((r) => r.id)).toEqual(['truenas-disk']);
|
||||
expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']);
|
||||
expect(model.resources.map((r) => r.id).sort()).toEqual(
|
||||
['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system'].sort(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
|
||||
export type TrueNASPageTabId = 'overview' | 'storage' | 'apps';
|
||||
export type TrueNASPageTabId = 'overview' | 'storage' | 'disks' | 'apps';
|
||||
|
||||
export type TrueNASTabSpec = {
|
||||
id: TrueNASPageTabId;
|
||||
|
|
@ -12,6 +12,7 @@ export type TrueNASTabSpec = {
|
|||
export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
|
||||
{ id: 'overview', label: 'Systems', path: '/truenas/overview' },
|
||||
{ id: 'storage', label: 'Storage', path: '/truenas/storage' },
|
||||
{ id: 'disks', label: 'Disks', path: '/truenas/disks' },
|
||||
{ id: 'apps', label: 'Apps', path: '/truenas/apps' },
|
||||
] as const;
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ const isTrueNASPlatform = (resource: Resource): boolean =>
|
|||
export type TrueNASPageModel = {
|
||||
resources: Resource[];
|
||||
systems: Resource[];
|
||||
disks: Resource[];
|
||||
apps: Resource[];
|
||||
};
|
||||
|
||||
|
|
@ -40,10 +42,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
|
|||
|
||||
const systems = trueNasResources.filter((resource) => resource.type === 'agent');
|
||||
const apps = trueNasResources.filter((resource) => resource.type === 'app-container');
|
||||
const disks = trueNasResources.filter((resource) => resource.type === 'physical_disk');
|
||||
|
||||
return {
|
||||
resources: trueNasResources,
|
||||
systems,
|
||||
disks,
|
||||
apps,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,16 +460,19 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time)
|
|||
records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.Disks))
|
||||
|
||||
totalCapacity, totalUsed := aggregatePoolUsage(snapshot.Pools)
|
||||
systemAgent := agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary)
|
||||
records = append(records, unifiedresources.IngestRecord{
|
||||
SourceID: systemSourceID,
|
||||
Resource: unifiedresources.Resource{
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
Name: strings.TrimSpace(snapshot.System.Hostname),
|
||||
Status: systemStatus(snapshot.System, systemRisk, systemIncidents),
|
||||
LastSeen: collectedAt,
|
||||
UpdatedAt: collectedAt,
|
||||
Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed),
|
||||
Agent: agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary),
|
||||
Type: unifiedresources.ResourceTypeAgent,
|
||||
Name: strings.TrimSpace(snapshot.System.Hostname),
|
||||
Status: systemStatus(snapshot.System, systemRisk, systemIncidents),
|
||||
LastSeen: collectedAt,
|
||||
UpdatedAt: collectedAt,
|
||||
Metrics: metricsFromTrueNASSystem(snapshot.System, totalCapacity, totalUsed),
|
||||
Uptime: snapshot.System.UptimeSeconds,
|
||||
Temperature: systemAgent.Temperature,
|
||||
Agent: systemAgent,
|
||||
TrueNAS: &unifiedresources.TrueNASData{
|
||||
Hostname: strings.TrimSpace(snapshot.System.Hostname),
|
||||
Version: snapshot.System.Version,
|
||||
|
|
|
|||
|
|
@ -82,8 +82,13 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
|
|||
rootPath: '/truenas',
|
||||
testId: 'truenas-page',
|
||||
ariaLabel: 'TrueNAS sections',
|
||||
tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'],
|
||||
populatedTabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/apps'],
|
||||
tabPaths: ['/truenas/overview', '/truenas/storage', '/truenas/disks', '/truenas/apps'],
|
||||
populatedTabPaths: [
|
||||
'/truenas/overview',
|
||||
'/truenas/storage',
|
||||
'/truenas/disks',
|
||||
'/truenas/apps',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vmware',
|
||||
|
|
@ -181,6 +186,7 @@ test.describe('Platform pages shell', () => {
|
|||
{ path: '/kubernetes/deployments', testId: 'kubernetes-page' },
|
||||
{ path: '/truenas/overview', testId: 'truenas-page' },
|
||||
{ path: '/truenas/storage', testId: 'truenas-page' },
|
||||
{ path: '/truenas/disks', testId: 'truenas-page' },
|
||||
{ path: '/truenas/apps', testId: 'truenas-page' },
|
||||
{ path: '/vmware/overview', testId: 'vmware-page' },
|
||||
{ path: '/vmware/vms', testId: 'vmware-page' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue