mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Adopt shared platform-table sorting on TrueNAS, vSphere, and Proxmox nodes tables
v5 parity follow-up to20cccc1a9, completing the adoption sweep after the Kubernetes pass (f66c4b46b). Adopts createPlatformTableSortState + PlatformSortableTableHead across the TrueNAS systems / apps / services / network shares / protection / storage topology / VMs tables and the vSphere hosts / datastores / networks / activity tables: per-table storage keys, descendingFirst on metric, count, and timestamp columns, the built-in order preserved until the user sorts, and canonical kind-based alignment via the sortable head. Composite summary columns (inventory, ports, images, access, clients, devices, flags, hosts, consumers) stay non-sortable. The TrueNAS storage topology sorts sibling groups and re-emits the tree depth-first, so pools re-order against pools and every dataset or disk stays under its parent; a new sort test pins that plus descending-first metrics and persistence. ProxmoxNodesTable drops its bespoke non-persisted createSignal sort for the same fabric, so the nodes sort now survives reloads like every other platform table. Its existing sort assertions pass unchanged; the suite now clears localStorage between tests since the sort persists by design. Also repairs the platformOverviewLayout guardrails at HEAD:f66c4b46blanded with the TrueNAS/vSphere head assertions already flipped to the kind form and the Kubernetes assertions still on the legacy form (a staging race between parallel agents); this commit carries the version with both sets on the kind form, which is green against the migrated tables.
This commit is contained in:
+3
-9
@@ -397,15 +397,9 @@ describe('platform overview layout guardrails', () => {
|
||||
expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,300}?Memory/);
|
||||
expect(dockerHostsTableSource).toMatch(/kind="metric-bar"[\s\S]{0,200}?Disk/);
|
||||
|
||||
expect(kubernetesClustersTableSource).toMatch(
|
||||
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Cluster/,
|
||||
);
|
||||
expect(kubernetesClustersTableSource).toMatch(
|
||||
/getPlatformTableHeadClassForKind\('numeric-value'\)[\s\S]{0,200}?Nodes/,
|
||||
);
|
||||
expect(kubernetesNodesTableSource).toMatch(
|
||||
/getPlatformTableHeadClassForKind\('name'\)[\s\S]{0,200}?Node/,
|
||||
);
|
||||
expect(kubernetesClustersTableSource).toMatch(/kind="name"[\s\S]{0,200}?Cluster/);
|
||||
expect(kubernetesClustersTableSource).toMatch(/kind="numeric-value"[\s\S]{0,200}?Nodes/);
|
||||
expect(kubernetesNodesTableSource).toMatch(/kind="name"[\s\S]{0,200}?Node/);
|
||||
expect(kubernetesNodesTableSource).toContain(
|
||||
'<span class="md:hidden">{compactCapacityLabel()}</span>',
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline'
|
||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getNodeExternalUrl } from '@/utils/nodes';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
@@ -31,14 +31,16 @@ import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory';
|
||||
import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTableShell,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTablePercentValue,
|
||||
formatPlatformTableUptimeValue,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformTableSortValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
|
||||
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
|
||||
@@ -125,15 +127,30 @@ const projectResourceToLegacyNode = (resource: Resource): LegacyNode => {
|
||||
return projected as unknown as LegacyNode;
|
||||
};
|
||||
|
||||
type HostSortKey = ProxmoxHostTableColumnId;
|
||||
// Every host column carries a scalar to order on, so the sortable-key list is
|
||||
// the full column-id list. Metric and count columns read "biggest on top", so
|
||||
// their first click sorts descending; the identity/context text columns start
|
||||
// ascending.
|
||||
const PROXMOX_HOST_SORT_KEYS = [
|
||||
'node',
|
||||
'version',
|
||||
'uptime',
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'temp',
|
||||
'vms',
|
||||
'cts',
|
||||
'cluster',
|
||||
] as const satisfies readonly ProxmoxHostTableColumnId[];
|
||||
|
||||
const TEXT_SORT_KEYS = new Set<ProxmoxHostTableColumnId>(['node', 'version', 'cluster']);
|
||||
type HostSortKey = (typeof PROXMOX_HOST_SORT_KEYS)[number];
|
||||
|
||||
const getHostSortValue = (
|
||||
node: Resource,
|
||||
guests: Resource[],
|
||||
key: HostSortKey,
|
||||
): string | number | null => {
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'node':
|
||||
return asTrimmedString(node.name) || node.id;
|
||||
@@ -164,19 +181,6 @@ const getHostSortValue = (
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyHostSortValue = (value: string | number | null): boolean =>
|
||||
value === null || (typeof value === 'number' && Number.isNaN(value));
|
||||
|
||||
const compareHostSortValues = (a: string | number | null, b: string | number | null): number => {
|
||||
const aEmpty = isEmptyHostSortValue(a);
|
||||
const bEmpty = isEmptyHostSortValue(b);
|
||||
if (aEmpty && bEmpty) return 0;
|
||||
if (aEmpty) return 1;
|
||||
if (bEmpty) return -1;
|
||||
if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1;
|
||||
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' });
|
||||
};
|
||||
|
||||
export const ProxmoxNodesTable: Component<{
|
||||
nodes: Resource[];
|
||||
guests: Resource[];
|
||||
@@ -196,40 +200,14 @@ export const ProxmoxNodesTable: Component<{
|
||||
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
|
||||
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
|
||||
const isSparklineMode = () => displayMode() === 'sparklines';
|
||||
const [sortKey, setSortKey] = createSignal<HostSortKey | null>(null);
|
||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||
|
||||
const handleSort = (column: ProxmoxHostTableColumnId) => {
|
||||
const key = column as HostSortKey;
|
||||
const defaultDirection = TEXT_SORT_KEYS.has(key) ? 'asc' : 'desc';
|
||||
// Cycle: default direction → flipped → cleared.
|
||||
if (sortKey() === key) {
|
||||
if (sortDirection() === defaultDirection) {
|
||||
setSortDirection(defaultDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(null);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection(defaultDirection);
|
||||
};
|
||||
|
||||
const sortedNodes = createMemo(() => {
|
||||
const key = sortKey();
|
||||
if (!key) return props.nodes;
|
||||
const dir = sortDirection() === 'asc' ? 1 : -1;
|
||||
const decorated = props.nodes.map(
|
||||
(node) => [getHostSortValue(node, props.guests, key), node] as const,
|
||||
);
|
||||
decorated.sort(([a], [b]) => {
|
||||
// Missing values stay last in either direction.
|
||||
if (isEmptyHostSortValue(a) || isEmptyHostSortValue(b)) return compareHostSortValues(a, b);
|
||||
return compareHostSortValues(a, b) * dir;
|
||||
});
|
||||
return decorated.map(([, node]) => node);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'proxmoxNodes',
|
||||
sortKeys: PROXMOX_HOST_SORT_KEYS,
|
||||
descendingFirst: ['uptime', 'cpu', 'memory', 'disk', 'temp', 'vms', 'cts'],
|
||||
});
|
||||
const sortedNodes = createMemo(() =>
|
||||
sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)),
|
||||
);
|
||||
|
||||
// Use the same canonical history reader the workloads table uses; cache
|
||||
// keys collide so the two readers dedupe their fetches.
|
||||
@@ -271,20 +249,9 @@ export const ProxmoxNodesTable: Component<{
|
||||
header={
|
||||
<For each={visibleColumns()}>
|
||||
{(column) => (
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind(column.kind)} cursor-pointer hover:bg-surface-hover`}
|
||||
aria-sort={
|
||||
sortKey() === column.id
|
||||
? sortDirection() === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleSort(column.id)}
|
||||
>
|
||||
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
|
||||
{column.label}
|
||||
{sortKey() === column.id && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The header sort persists per table via localStorage; clear it so one
|
||||
// test's sort choice cannot leak into the next render.
|
||||
window.localStorage.clear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,21 +2,23 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getShortImageName } from '@/utils/format';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableTitleCaseValue,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -121,6 +123,56 @@ const UpdatePills: Component<{ app: ResourceTrueNASAppMeta | undefined }> = (pro
|
||||
);
|
||||
};
|
||||
|
||||
// Columns a user can sort by. Ports and Images summarize several values at
|
||||
// once, so they carry no single scalar to order on. Updates orders on how
|
||||
// many update kinds (app / image) are pending so outdated apps surface first.
|
||||
const TRUENAS_APP_SORT_KEYS = [
|
||||
'app',
|
||||
'version',
|
||||
'cpu',
|
||||
'memory',
|
||||
'containers',
|
||||
'updates',
|
||||
] as const;
|
||||
|
||||
type TrueNASAppSortKey = (typeof TRUENAS_APP_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASAppSortValue = (
|
||||
resource: Resource,
|
||||
key: TrueNASAppSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
const app = appMeta(resource);
|
||||
switch (key) {
|
||||
case 'app':
|
||||
return (
|
||||
asTrimmedString(app?.name) ||
|
||||
asTrimmedString(resource.displayName) ||
|
||||
asTrimmedString(resource.name) ||
|
||||
resource.id
|
||||
);
|
||||
case 'version': {
|
||||
const version = appVersionLabel(app);
|
||||
return version === '-' ? null : version;
|
||||
}
|
||||
case 'cpu':
|
||||
return getPlatformTableFiniteMetric(resource.cpu?.current) ?? null;
|
||||
case 'memory': {
|
||||
const total = getPlatformTableFiniteMetric(resource.memory?.total) ?? 0;
|
||||
if (total > 0) {
|
||||
return ((getPlatformTableFiniteMetric(resource.memory?.used) ?? 0) / total) * 100;
|
||||
}
|
||||
return getPlatformTableFiniteMetric(resource.memory?.current) ?? null;
|
||||
}
|
||||
case 'containers':
|
||||
return appContainerCount(app);
|
||||
case 'updates':
|
||||
return (app?.upgradeAvailable === true ? 1 : 0) + (app?.imageUpdatesAvailable === true ? 1 : 0);
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TrueNASAppsTable: Component<{
|
||||
apps: Resource[];
|
||||
scope: Resource[];
|
||||
@@ -136,6 +188,12 @@ export const TrueNASAppsTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-app-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasApps',
|
||||
sortKeys: TRUENAS_APP_SORT_KEYS,
|
||||
descendingFirst: ['cpu', 'memory', 'containers', 'updates'],
|
||||
});
|
||||
const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getTrueNASAppSortValue));
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -178,43 +236,60 @@ export const TrueNASAppsTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[17%]`}>
|
||||
<PlatformSortableTableHead kind="name" sort={sort} sortKey="app" class="md:w-[17%]">
|
||||
App
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="version"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Version
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[9%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="cpu"
|
||||
class="md:w-[9%]"
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[12%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="containers"
|
||||
class="hidden sm:table-cell md:w-[12%]"
|
||||
>
|
||||
Containers
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
|
||||
Ports
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[12%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[12%]">
|
||||
Images
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="updates"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Updates
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => {
|
||||
const app = () => appMeta(resource);
|
||||
const name = () =>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableTitleCaseValue,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
summarizePlatformTableValues,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -88,6 +90,34 @@ const shareName = (resource: Resource, share: ResourceTrueNASShareMeta | undefin
|
||||
asTrimmedString(resource.name) ||
|
||||
resource.id;
|
||||
|
||||
// Columns a user can sort by. Access and Clients summarize several flags or
|
||||
// endpoints at once, so they carry no single scalar to order on.
|
||||
const TRUENAS_SHARE_SORT_KEYS = ['share', 'protocol', 'path', 'state'] as const;
|
||||
|
||||
type TrueNASShareSortKey = (typeof TRUENAS_SHARE_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASShareSortValue = (
|
||||
resource: Resource,
|
||||
key: TrueNASShareSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
const share = shareMeta(resource);
|
||||
switch (key) {
|
||||
case 'share':
|
||||
return shareName(resource, share);
|
||||
case 'protocol': {
|
||||
const protocol = formatProtocol(share);
|
||||
return protocol === '-' ? null : protocol;
|
||||
}
|
||||
case 'path':
|
||||
return asTrimmedString(share?.path) || null;
|
||||
case 'state':
|
||||
return formatPlatformTableTitleCaseValue(mapTrueNASShareStatus(resource));
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TrueNASNetworkSharesTable: Component<{
|
||||
shares: Resource[];
|
||||
scope: Resource[];
|
||||
@@ -103,6 +133,13 @@ export const TrueNASNetworkSharesTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-share-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasShares',
|
||||
sortKeys: TRUENAS_SHARE_SORT_KEYS,
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getTrueNASShareSortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -145,33 +182,49 @@ export const TrueNASNetworkSharesTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[23%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="share"
|
||||
class="md:w-[23%]"
|
||||
>
|
||||
Share
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[11%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="protocol"
|
||||
class="md:w-[11%]"
|
||||
>
|
||||
Protocol
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[27%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="path"
|
||||
class="hidden md:table-cell md:w-[27%]"
|
||||
>
|
||||
Path
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[19%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[19%]">
|
||||
Access
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[13%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[13%]">
|
||||
Clients
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[7%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="state"
|
||||
class="md:w-[7%]"
|
||||
>
|
||||
State
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => {
|
||||
const share = () => shareMeta(resource);
|
||||
const name = () => shareName(resource, share());
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary';
|
||||
import {
|
||||
getRecoveryOutcomeBadgeClass,
|
||||
@@ -13,15 +13,18 @@ import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
import {
|
||||
PlatformErrorState,
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableDateTimeValue,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableLoadingState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableBytesValue,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
getPlatformTableDateTimeSortValue,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -167,6 +170,49 @@ const targetSecondaryLabel = (point: RecoveryPoint): string => {
|
||||
const sizeLabel = (point: RecoveryPoint): string =>
|
||||
formatPlatformTableBytesValue(point.sizeBytes ?? undefined, '-');
|
||||
|
||||
// Columns a user can sort by. Every protection column carries a scalar:
|
||||
// Completed orders chronologically and Size on raw bytes, both newest/biggest
|
||||
// first on the first click.
|
||||
const TRUENAS_PROTECTION_SORT_KEYS = [
|
||||
'dataset',
|
||||
'artifact',
|
||||
'target',
|
||||
'completed',
|
||||
'size',
|
||||
'outcome',
|
||||
] as const;
|
||||
|
||||
type TrueNASProtectionSortKey = (typeof TRUENAS_PROTECTION_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASProtectionSortValue = (
|
||||
point: RecoveryPoint,
|
||||
key: TrueNASProtectionSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'dataset':
|
||||
return datasetLabel(point);
|
||||
case 'artifact':
|
||||
return artifactLabel(point);
|
||||
case 'target': {
|
||||
const target = targetLabel(point);
|
||||
return target === '-' ? null : target;
|
||||
}
|
||||
case 'completed':
|
||||
return getPlatformTableDateTimeSortValue(
|
||||
asTrimmedString(point.completedAt) || asTrimmedString(point.startedAt),
|
||||
);
|
||||
case 'size':
|
||||
return typeof point.sizeBytes === 'number' && Number.isFinite(point.sizeBytes)
|
||||
? point.sizeBytes
|
||||
: null;
|
||||
case 'outcome':
|
||||
return getRecoveryOutcomeLabel(normalizeRecoveryOutcome(point.outcome));
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type ProtectionDetailTone = DetailValueTone;
|
||||
type ProtectionDetailSection = DetailSection;
|
||||
|
||||
@@ -312,6 +358,14 @@ export const TrueNASProtectionTable: Component<{
|
||||
initialStatus: 'all' as TrueNASProtectionStatusFilter,
|
||||
filter: filterTrueNASProtectionPoints,
|
||||
});
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasProtection',
|
||||
sortKeys: TRUENAS_PROTECTION_SORT_KEYS,
|
||||
descendingFirst: ['completed', 'size'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getTrueNASProtectionSortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -416,35 +470,59 @@ export const TrueNASProtectionTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="dataset"
|
||||
class="md:w-[22%]"
|
||||
>
|
||||
Dataset
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[25%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="artifact"
|
||||
class="md:w-[25%]"
|
||||
>
|
||||
Artifact
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[23%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="target"
|
||||
class="hidden md:table-cell md:w-[23%]"
|
||||
>
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="completed"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
Completed
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="size"
|
||||
class="hidden lg:table-cell md:w-[8%]"
|
||||
>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[9%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="outcome"
|
||||
class="md:w-[9%]"
|
||||
>
|
||||
Outcome
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(point) => {
|
||||
const outcome = () => normalizeRecoveryOutcome(point.outcome);
|
||||
const artifact = () => artifactLabel(point);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableTitleCaseValue,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -151,6 +153,36 @@ const ServiceDetailTable: Component<{ row: TrueNASServiceRow; onClose: () => voi
|
||||
/>
|
||||
);
|
||||
|
||||
// Columns a user can sort by. PIDs orders on the process count so the busiest
|
||||
// services surface first.
|
||||
const TRUENAS_SERVICE_SORT_KEYS = ['service', 'state', 'boot', 'pids', 'system'] as const;
|
||||
|
||||
type TrueNASServiceSortKey = (typeof TRUENAS_SERVICE_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASServiceSortValue = (
|
||||
row: TrueNASServiceRow,
|
||||
key: TrueNASServiceSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'service':
|
||||
return formatServiceName(row.service.service);
|
||||
case 'state':
|
||||
return asTrimmedString(row.service.state) || null;
|
||||
case 'boot':
|
||||
return row.service.enabled ? 'Enabled' : 'Disabled';
|
||||
case 'pids': {
|
||||
const count = (row.service.pids ?? []).filter((pid) => Number.isFinite(pid) && pid > 0)
|
||||
.length;
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
case 'system':
|
||||
return asTrimmedString(row.systemName) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TrueNASServicesTable: Component<{
|
||||
services: TrueNASServiceRow[];
|
||||
emptyIcon: JSX.Element;
|
||||
@@ -164,6 +196,14 @@ export const TrueNASServicesTable: Component<{
|
||||
filter: filterTrueNASServices,
|
||||
});
|
||||
const detail = createPlatformResourceDetailState({ idPrefix: 'truenas-service-detail' });
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasServices',
|
||||
sortKeys: TRUENAS_SERVICE_SORT_KEYS,
|
||||
descendingFirst: ['pids'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getTrueNASServiceSortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -206,28 +246,51 @@ export const TrueNASServicesTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[900px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="service"
|
||||
class="md:w-[24%]"
|
||||
>
|
||||
Service
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="state"
|
||||
class="md:w-[14%]"
|
||||
>
|
||||
State
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="boot"
|
||||
class="md:w-[14%]"
|
||||
>
|
||||
Boot
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden sm:table-cell md:w-[20%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="pids"
|
||||
class="hidden sm:table-cell md:w-[20%]"
|
||||
>
|
||||
PIDs
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[28%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="system"
|
||||
class="md:w-[28%]"
|
||||
>
|
||||
System
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(row) => {
|
||||
const status = () => mapTrueNASServiceStatus(row);
|
||||
const pids = createMemo(() => formatPIDs(row.service.pids));
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableNumberValue,
|
||||
PlatformTableTemperatureValue,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableBytesValue,
|
||||
formatPlatformTableTitleCaseValue,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortState,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -162,6 +165,80 @@ const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
// Columns a user can sort by. Usage / Size orders pools and datasets on their
|
||||
// used percentage and disks on their raw size, so same-kind siblings compare
|
||||
// on the number their cell actually shows.
|
||||
const TRUENAS_STORAGE_SORT_KEYS = ['resource', 'kind', 'usage', 'disks', 'temp', 'health'] as const;
|
||||
|
||||
type TrueNASStorageSortKey = (typeof TRUENAS_STORAGE_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASStorageSortValue = (
|
||||
row: TrueNASStorageTopologyRow,
|
||||
key: TrueNASStorageSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'resource':
|
||||
return resourceName(row.resource);
|
||||
case 'kind':
|
||||
return kindLabel(row.kind);
|
||||
case 'usage': {
|
||||
if (row.kind === 'disk') {
|
||||
const size = row.resource.physicalDisk?.sizeBytes;
|
||||
return typeof size === 'number' && Number.isFinite(size) ? size : null;
|
||||
}
|
||||
return capacityPercent(row.resource) ?? null;
|
||||
}
|
||||
case 'disks':
|
||||
return row.kind === 'pool' ? row.counts.disks : null;
|
||||
case 'temp': {
|
||||
if (row.kind !== 'disk') return null;
|
||||
const temperature = row.resource.physicalDisk?.temperature;
|
||||
return typeof temperature === 'number' && Number.isFinite(temperature) ? temperature : null;
|
||||
}
|
||||
case 'health':
|
||||
return riskLabel(row);
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The topology renders as a flattened tree (pool → datasets → disks with
|
||||
// indentation), so a flat re-order would tear children away from their
|
||||
// parents. Sort each sibling group instead and re-emit depth-first: pools
|
||||
// re-order against pools, and every parent keeps its subtree directly below
|
||||
// it. Rows whose parent was filtered out re-root, matching how the filter
|
||||
// already presents them.
|
||||
const sortTrueNASStorageTopologyRows = (
|
||||
rows: readonly TrueNASStorageTopologyRow[],
|
||||
sort: PlatformTableSortState<TrueNASStorageSortKey>,
|
||||
): readonly TrueNASStorageTopologyRow[] => {
|
||||
if (!sort.sortKey()) return rows;
|
||||
const present = new Set(rows.map((row) => row.id));
|
||||
const roots: TrueNASStorageTopologyRow[] = [];
|
||||
const childrenByParent = new Map<string, TrueNASStorageTopologyRow[]>();
|
||||
for (const row of rows) {
|
||||
const parentRowId = row.parentRowId && present.has(row.parentRowId) ? row.parentRowId : '';
|
||||
if (!parentRowId) {
|
||||
roots.push(row);
|
||||
continue;
|
||||
}
|
||||
const siblings = childrenByParent.get(parentRowId) ?? [];
|
||||
siblings.push(row);
|
||||
childrenByParent.set(parentRowId, siblings);
|
||||
}
|
||||
const ordered: TrueNASStorageTopologyRow[] = [];
|
||||
const emit = (siblings: TrueNASStorageTopologyRow[]) => {
|
||||
for (const row of sort.sortRows(siblings, getTrueNASStorageSortValue)) {
|
||||
ordered.push(row);
|
||||
const children = childrenByParent.get(row.id);
|
||||
if (children) emit(children);
|
||||
}
|
||||
};
|
||||
emit(roots);
|
||||
return ordered;
|
||||
};
|
||||
|
||||
export const getTrueNASStorageTopologyIndentClass = (depth: number): string => {
|
||||
if (depth <= 0) return '';
|
||||
if (depth === 1) return 'pl-5 sm:pl-7';
|
||||
@@ -212,6 +289,12 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-storage-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasStorage',
|
||||
sortKeys: TRUENAS_STORAGE_SORT_KEYS,
|
||||
descendingFirst: ['usage', 'disks', 'temp'],
|
||||
});
|
||||
const sortedRows = createMemo(() => sortTrueNASStorageTopologyRows(tableState.filtered(), sort));
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -254,33 +337,59 @@ export const TrueNASStorageTopologyTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[32%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="resource"
|
||||
class="md:w-[32%]"
|
||||
>
|
||||
Resource
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="kind"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Kind
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[28%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="usage"
|
||||
class="md:w-[28%]"
|
||||
>
|
||||
Usage / Size
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="disks"
|
||||
class="hidden md:table-cell md:w-[8%]"
|
||||
>
|
||||
Disks
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="temp"
|
||||
class="hidden lg:table-cell md:w-[8%]"
|
||||
>
|
||||
Temp
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="health"
|
||||
class="md:w-[14%]"
|
||||
>
|
||||
Health
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(row) => {
|
||||
const resource = () => row.resource;
|
||||
const detailRowId = () => drawer.detailRowId(resource());
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getAlertStyles } from '@/utils/alerts';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
@@ -11,6 +11,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTableNumberValue,
|
||||
PlatformTablePercentValue,
|
||||
@@ -18,13 +19,14 @@ import {
|
||||
PlatformTableToolbar,
|
||||
PlatformTableEmptyState,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
filterPlatformResources,
|
||||
formatPlatformTableBytesValue,
|
||||
formatPlatformTableUptimeValue,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformResourceStatusFilter,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -78,6 +80,57 @@ const workloadInventoryPrimary = (counts: TrueNASSystemChildCounts): string =>
|
||||
const workloadInventorySecondary = (counts: TrueNASSystemChildCounts): string =>
|
||||
plural(counts.apps, 'app');
|
||||
|
||||
// Columns a user can sort by. The Inventory and VMs / Apps columns summarize
|
||||
// several counts at once, so they carry no single scalar to order on.
|
||||
const TRUENAS_SYSTEM_SORT_KEYS = [
|
||||
'system',
|
||||
'cpu',
|
||||
'memory',
|
||||
'capacity',
|
||||
'temp',
|
||||
'shares',
|
||||
'services',
|
||||
] as const;
|
||||
|
||||
type TrueNASSystemSortKey = (typeof TRUENAS_SYSTEM_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASSystemSortValue = (
|
||||
system: Resource,
|
||||
counts: TrueNASSystemChildCounts,
|
||||
key: TrueNASSystemSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'system':
|
||||
return asTrimmedString(system.name) || system.id;
|
||||
case 'cpu':
|
||||
return getPlatformTableFiniteMetric(system.cpu?.current) ?? null;
|
||||
case 'memory': {
|
||||
const total = getPlatformTableFiniteMetric(system.memory?.total) ?? 0;
|
||||
if (total > 0) {
|
||||
return ((getPlatformTableFiniteMetric(system.memory?.used) ?? 0) / total) * 100;
|
||||
}
|
||||
return getPlatformTableFiniteMetric(system.memory?.current) ?? null;
|
||||
}
|
||||
case 'capacity': {
|
||||
const current = getPlatformTableFiniteMetric(system.disk?.current);
|
||||
if (current !== undefined) return current;
|
||||
const used = getPlatformTableFiniteMetric(system.disk?.used);
|
||||
const total = getPlatformTableFiniteMetric(system.disk?.total);
|
||||
if (used !== undefined && total !== undefined && total > 0) return (used / total) * 100;
|
||||
return null;
|
||||
}
|
||||
case 'temp':
|
||||
return getPlatformTableFiniteMetric(system.temperature) ?? null;
|
||||
case 'shares':
|
||||
return counts.shares;
|
||||
case 'services':
|
||||
return counts.services;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TrueNASSystemsTable: Component<{
|
||||
systems: Resource[];
|
||||
// Full TrueNAS resource scope so we can count pools / datasets / apps
|
||||
@@ -103,6 +156,16 @@ export const TrueNASSystemsTable: Component<{
|
||||
const countsBySystem = createMemo(() =>
|
||||
buildTrueNASSystemChildCounts(props.scope, props.systems),
|
||||
);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasSystems',
|
||||
sortKeys: TRUENAS_SYSTEM_SORT_KEYS,
|
||||
descendingFirst: ['cpu', 'memory', 'capacity', 'temp', 'shares', 'services'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), (system, key) =>
|
||||
getTrueNASSystemSortValue(system, countsBySystem().get(system.id) ?? EMPTY_COUNTS, key),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -145,48 +208,73 @@ export const TrueNASSystemsTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[17%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="system"
|
||||
class="md:w-[17%]"
|
||||
>
|
||||
System
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="cpu"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="capacity"
|
||||
class="md:w-[13%]"
|
||||
>
|
||||
Capacity
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="temp"
|
||||
class="hidden md:table-cell md:w-[6%]"
|
||||
>
|
||||
Temp
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell lg:w-[15%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell lg:w-[15%]">
|
||||
Inventory
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="shares"
|
||||
class="hidden lg:table-cell lg:w-[8%]"
|
||||
>
|
||||
Shares
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell lg:w-[10%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell lg:w-[10%]">
|
||||
VMs / Apps
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden lg:table-cell lg:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="services"
|
||||
class="hidden lg:table-cell lg:w-[10%]"
|
||||
>
|
||||
Services
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(system) => {
|
||||
const name = () => asTrimmedString(system.name) || system.id;
|
||||
const version = () => asTrimmedString(system.agent?.osVersion) || '—';
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
formatPlatformTableBytesValue,
|
||||
formatPlatformTableTitleCaseValue,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -87,6 +89,58 @@ const flagLabels = (vm: ResourceTrueNASVMMeta | undefined): string[] => {
|
||||
return labels;
|
||||
};
|
||||
|
||||
// Columns a user can sort by. Devices and Flags summarize several values at
|
||||
// once, so they carry no single scalar to order on. CPU orders on the
|
||||
// provisioned vCPU count (cores × threads when vCPUs are not reported) and
|
||||
// Memory on the provisioned bytes.
|
||||
const TRUENAS_VM_SORT_KEYS = ['vm', 'state', 'cpu', 'memory', 'boot'] as const;
|
||||
|
||||
type TrueNASVMSortKey = (typeof TRUENAS_VM_SORT_KEYS)[number];
|
||||
|
||||
const getTrueNASVMSortValue = (
|
||||
resource: Resource,
|
||||
key: TrueNASVMSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
const vm = vmMeta(resource);
|
||||
switch (key) {
|
||||
case 'vm':
|
||||
return (
|
||||
asTrimmedString(vm?.name) ||
|
||||
asTrimmedString(resource.displayName) ||
|
||||
asTrimmedString(resource.name) ||
|
||||
resource.id
|
||||
);
|
||||
case 'state':
|
||||
return asTrimmedString(vm?.state || vm?.domainState) || null;
|
||||
case 'cpu': {
|
||||
const vcpus = vm?.vcpus;
|
||||
if (typeof vcpus === 'number' && Number.isFinite(vcpus) && vcpus > 0) return vcpus;
|
||||
const cores = vm?.cores;
|
||||
const threads = vm?.threads;
|
||||
if (
|
||||
typeof cores === 'number' &&
|
||||
Number.isFinite(cores) &&
|
||||
cores > 0 &&
|
||||
typeof threads === 'number' &&
|
||||
Number.isFinite(threads) &&
|
||||
threads > 0
|
||||
) {
|
||||
return cores * threads;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'memory':
|
||||
return typeof vm?.memoryBytes === 'number' && Number.isFinite(vm.memoryBytes)
|
||||
? vm.memoryBytes
|
||||
: null;
|
||||
case 'boot':
|
||||
return asTrimmedString(vm?.bootloader) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const TrueNASVirtualMachinesTable: Component<{
|
||||
vms: Resource[];
|
||||
scope: Resource[];
|
||||
@@ -102,6 +156,12 @@ export const TrueNASVirtualMachinesTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-vm-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'truenasVms',
|
||||
sortKeys: TRUENAS_VM_SORT_KEYS,
|
||||
descendingFirst: ['cpu', 'memory'],
|
||||
});
|
||||
const sortedRows = createMemo(() => sort.sortRows(tableState.filtered(), getTrueNASVMSortValue));
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -144,40 +204,52 @@ export const TrueNASVirtualMachinesTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[960px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
|
||||
<PlatformSortableTableHead kind="name" sort={sort} sortKey="vm" class="md:w-[22%]">
|
||||
VM
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="state"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
State
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="cpu"
|
||||
class="hidden sm:table-cell md:w-[10%]"
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="hidden sm:table-cell md:w-[10%]"
|
||||
>
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[11%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="boot"
|
||||
class="hidden md:table-cell md:w-[11%]"
|
||||
>
|
||||
Boot
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
|
||||
Devices
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[19%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="md:w-[19%]">
|
||||
Flags
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(resource) => {
|
||||
const vm = () => vmMeta(resource);
|
||||
const name = () =>
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TrueNASStorageTopologyTable } from '@/features/truenas/TrueNASStorageTopologyTable';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
// The capacity bar observes its container size; jsdom has no ResizeObserver,
|
||||
// and this suite only asserts row order.
|
||||
vi.mock('@/components/shared/responsive', () => ({
|
||||
ResponsiveMetricCell: (props: { type: string }) => (
|
||||
<div data-testid={`responsive-${props.type}-metric`} />
|
||||
),
|
||||
}));
|
||||
|
||||
const makeStorageResource = (overrides: Partial<Resource> & Pick<Resource, 'id'>): Resource =>
|
||||
({
|
||||
type: 'storage',
|
||||
name: overrides.id,
|
||||
displayName: overrides.id,
|
||||
status: 'online',
|
||||
platformType: 'truenas',
|
||||
platformScopes: ['truenas'],
|
||||
sourceType: 'api',
|
||||
storage: { topology: 'dataset', platform: 'truenas' },
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
// Two pools with root datasets so both levels of the topology are sortable:
|
||||
// the pools against each other and the datasets within each pool's subtree.
|
||||
const FIXTURE: Resource[] = [
|
||||
makeStorageResource({
|
||||
id: 'pool-alpha',
|
||||
name: 'alpha',
|
||||
displayName: 'alpha',
|
||||
disk: { current: 50 } as Resource['disk'],
|
||||
storage: { topology: 'pool', platform: 'truenas', path: 'alpha' },
|
||||
}),
|
||||
makeStorageResource({
|
||||
id: 'dataset-zeta',
|
||||
name: 'alpha/zeta',
|
||||
displayName: 'alpha/zeta',
|
||||
disk: { current: 10 } as Resource['disk'],
|
||||
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/zeta' },
|
||||
}),
|
||||
makeStorageResource({
|
||||
id: 'dataset-mike',
|
||||
name: 'alpha/mike',
|
||||
displayName: 'alpha/mike',
|
||||
disk: { current: 80 } as Resource['disk'],
|
||||
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/mike' },
|
||||
}),
|
||||
// No disk metric: must sink to the bottom of its sibling group when the
|
||||
// user sorts on Usage / Size.
|
||||
makeStorageResource({
|
||||
id: 'dataset-bravo',
|
||||
name: 'alpha/bravo',
|
||||
displayName: 'alpha/bravo',
|
||||
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/alpha/bravo' },
|
||||
}),
|
||||
makeStorageResource({
|
||||
id: 'pool-zulu',
|
||||
name: 'zulu',
|
||||
displayName: 'zulu',
|
||||
disk: { current: 70 } as Resource['disk'],
|
||||
storage: { topology: 'pool', platform: 'truenas', path: 'zulu' },
|
||||
}),
|
||||
makeStorageResource({
|
||||
id: 'dataset-data',
|
||||
name: 'zulu/data',
|
||||
displayName: 'zulu/data',
|
||||
disk: { current: 30 } as Resource['disk'],
|
||||
storage: { topology: 'dataset', platform: 'truenas', path: '/mnt/zulu/data' },
|
||||
}),
|
||||
];
|
||||
|
||||
const renderTable = () =>
|
||||
render(() => (
|
||||
<TrueNASStorageTopologyTable
|
||||
resources={FIXTURE}
|
||||
scope={FIXTURE}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No storage"
|
||||
emptyDescription="No storage"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
const visibleRowOrder = (container: HTMLElement): string[] =>
|
||||
Array.from(container.querySelectorAll('tr[data-truenas-storage-row]')).map(
|
||||
(row) => row.getAttribute('data-truenas-storage-row') ?? '',
|
||||
);
|
||||
|
||||
const headerFor = (label: string): HTMLElement => {
|
||||
const header = screen
|
||||
.getAllByRole('columnheader')
|
||||
.find((th) => th.textContent?.trim().startsWith(label));
|
||||
if (!header) throw new Error(`No column header labelled ${label}`);
|
||||
return header;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('TrueNASStorageTopologyTable user sorting', () => {
|
||||
it('sorts sibling groups without tearing subtrees from their pools', () => {
|
||||
const { container } = renderTable();
|
||||
|
||||
// Built-in order: pools alphabetically, each with its datasets below it.
|
||||
expect(visibleRowOrder(container)).toEqual([
|
||||
'pool:pool-alpha',
|
||||
'dataset:dataset-bravo',
|
||||
'dataset:dataset-mike',
|
||||
'dataset:dataset-zeta',
|
||||
'pool:pool-zulu',
|
||||
'dataset:dataset-data',
|
||||
]);
|
||||
|
||||
fireEvent.click(headerFor('Resource'));
|
||||
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'ascending');
|
||||
expect(visibleRowOrder(container)).toEqual([
|
||||
'pool:pool-alpha',
|
||||
'dataset:dataset-bravo',
|
||||
'dataset:dataset-mike',
|
||||
'dataset:dataset-zeta',
|
||||
'pool:pool-zulu',
|
||||
'dataset:dataset-data',
|
||||
]);
|
||||
|
||||
// Descending re-orders the pools AND the datasets within each pool, but
|
||||
// every dataset stays directly below its own pool.
|
||||
fireEvent.click(headerFor('Resource'));
|
||||
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'descending');
|
||||
expect(visibleRowOrder(container)).toEqual([
|
||||
'pool:pool-zulu',
|
||||
'dataset:dataset-data',
|
||||
'pool:pool-alpha',
|
||||
'dataset:dataset-zeta',
|
||||
'dataset:dataset-mike',
|
||||
'dataset:dataset-bravo',
|
||||
]);
|
||||
|
||||
// Third click clears back to the built-in topology order.
|
||||
fireEvent.click(headerFor('Resource'));
|
||||
expect(headerFor('Resource')).not.toHaveAttribute('aria-sort');
|
||||
expect(visibleRowOrder(container)).toEqual([
|
||||
'pool:pool-alpha',
|
||||
'dataset:dataset-bravo',
|
||||
'dataset:dataset-mike',
|
||||
'dataset:dataset-zeta',
|
||||
'pool:pool-zulu',
|
||||
'dataset:dataset-data',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts Usage / Size descending first with missing values last in each subtree', () => {
|
||||
const { container } = renderTable();
|
||||
|
||||
fireEvent.click(headerFor('Usage / Size'));
|
||||
expect(headerFor('Usage / Size')).toHaveAttribute('aria-sort', 'descending');
|
||||
// Pools order on their own usage (zulu 70 > alpha 50); alpha's datasets
|
||||
// order 80 > 10 with the metric-less bravo sinking to the bottom of its
|
||||
// sibling group.
|
||||
expect(visibleRowOrder(container)).toEqual([
|
||||
'pool:pool-zulu',
|
||||
'dataset:dataset-data',
|
||||
'pool:pool-alpha',
|
||||
'dataset:dataset-mike',
|
||||
'dataset:dataset-zeta',
|
||||
'dataset:dataset-bravo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists the chosen sort across a remount', () => {
|
||||
const first = renderTable();
|
||||
fireEvent.click(headerFor('Resource'));
|
||||
fireEvent.click(headerFor('Resource'));
|
||||
expect(window.localStorage.getItem('truenasStorageSortKey')).toBe('resource');
|
||||
expect(window.localStorage.getItem('truenasStorageSortDirection')).toBe('desc');
|
||||
expect(visibleRowOrder(first.container)[0]).toBe('pool:pool-zulu');
|
||||
|
||||
cleanup();
|
||||
|
||||
const second = renderTable();
|
||||
expect(headerFor('Resource')).toHaveAttribute('aria-sort', 'descending');
|
||||
expect(visibleRowOrder(second.container)[0]).toBe('pool:pool-zulu');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import {
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
@@ -9,16 +9,19 @@ import {
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableDateTimeValue,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
getPlatformTableDateTimeSortValue,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -193,6 +196,49 @@ const ActivityDetail: Component<{ activity: VmwareActivityRow; onClose: () => vo
|
||||
/>
|
||||
);
|
||||
|
||||
// Columns a user can sort by: every activity column carries a scalar. When
|
||||
// orders chronologically, newest first on the first click.
|
||||
const VSPHERE_ACTIVITY_SORT_KEYS = [
|
||||
'resource',
|
||||
'type',
|
||||
'activity',
|
||||
'state',
|
||||
'actor',
|
||||
'vcenter',
|
||||
'when',
|
||||
] as const;
|
||||
|
||||
type VsphereActivitySortKey = (typeof VSPHERE_ACTIVITY_SORT_KEYS)[number];
|
||||
|
||||
const getVsphereActivitySortValue = (
|
||||
activity: VmwareActivityRow,
|
||||
key: VsphereActivitySortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'resource':
|
||||
return activity.resourceName || null;
|
||||
case 'type':
|
||||
return formatActivityKind(activity.activityKind);
|
||||
case 'activity':
|
||||
return activity.title || null;
|
||||
case 'state': {
|
||||
const state = formatActivityState(activity);
|
||||
return state === '-' ? null : state;
|
||||
}
|
||||
case 'actor':
|
||||
return activity.actor || null;
|
||||
case 'vcenter': {
|
||||
const meta = activity.resource.vmware;
|
||||
return meta?.connectionName || meta?.vcenterHost || null;
|
||||
}
|
||||
case 'when':
|
||||
return getPlatformTableDateTimeSortValue(activity.occurredAt || activity.observedAt);
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const VsphereActivityTable: Component<{
|
||||
activity: VmwareActivityRow[];
|
||||
emptyIcon: JSX.Element;
|
||||
@@ -206,6 +252,14 @@ export const VsphereActivityTable: Component<{
|
||||
filter: filterVmwareActivity,
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-activity-drawer' });
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'vsphereActivity',
|
||||
sortKeys: VSPHERE_ACTIVITY_SORT_KEYS,
|
||||
descendingFirst: ['when'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getVsphereActivitySortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -248,38 +302,67 @@ export const VsphereActivityTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[20%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="resource"
|
||||
class="md:w-[20%]"
|
||||
>
|
||||
Resource
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="type"
|
||||
class="md:w-[8%]"
|
||||
>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[34%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="activity"
|
||||
class="md:w-[34%]"
|
||||
>
|
||||
Activity
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="badge"
|
||||
sort={sort}
|
||||
sortKey="state"
|
||||
class="md:w-[10%]"
|
||||
>
|
||||
State
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="actor"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Actor
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden lg:table-cell md:w-[10%]"
|
||||
>
|
||||
vCenter
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden xl:table-cell md:w-[8%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="when"
|
||||
class="hidden xl:table-cell md:w-[8%]"
|
||||
>
|
||||
When
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(activity) => {
|
||||
const meta = () => activity.resource.vmware;
|
||||
const detailRowId = () => drawer.detailRowId(activity);
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
summarizePlatformTableValues,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -96,6 +98,36 @@ const hasCapacityMetric = (resource: Resource): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
// Columns a user can sort by. Hosts and Consumers summarize several names at
|
||||
// once, so they carry no single scalar to order on. Capacity orders on the
|
||||
// used percentage the bar shows.
|
||||
const VSPHERE_DATASTORE_SORT_KEYS = ['datastore', 'type', 'capacity', 'vms', 'datacenter'] as const;
|
||||
|
||||
type VsphereDatastoreSortKey = (typeof VSPHERE_DATASTORE_SORT_KEYS)[number];
|
||||
|
||||
const getVsphereDatastoreSortValue = (
|
||||
resource: Resource,
|
||||
key: VsphereDatastoreSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'datastore':
|
||||
return datastoreName(resource);
|
||||
case 'type': {
|
||||
const type = datastoreType(resource);
|
||||
return type === '—' ? null : type;
|
||||
}
|
||||
case 'capacity':
|
||||
return hasCapacityMetric(resource) ? capacityDisk(resource).usage : null;
|
||||
case 'vms':
|
||||
return consumerCount(resource);
|
||||
case 'datacenter':
|
||||
return asTrimmedString(resource.vmware?.datacenterName) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const VsphereDatastoresTable: Component<{
|
||||
datastores: Resource[];
|
||||
scope: Resource[];
|
||||
@@ -111,6 +143,14 @@ export const VsphereDatastoresTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-datastore-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'vsphereDatastores',
|
||||
sortKeys: VSPHERE_DATASTORE_SORT_KEYS,
|
||||
descendingFirst: ['capacity', 'vms'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getVsphereDatastoreSortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -153,40 +193,57 @@ export const VsphereDatastoresTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[1080px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[21%]`}>
|
||||
Datastore
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[9%]`}>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[18%]`}>
|
||||
Capacity
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="datastore"
|
||||
class="md:w-[21%]"
|
||||
>
|
||||
Datastore
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="type"
|
||||
class="md:w-[9%]"
|
||||
>
|
||||
Type
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="capacity"
|
||||
class="md:w-[18%]"
|
||||
>
|
||||
Capacity
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[14%]">
|
||||
Hosts
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="vms"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
VMs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[13%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[13%]">
|
||||
Consumers
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="datacenter"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Datacenter
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(datastore) => {
|
||||
const hosts = createMemo(() => hostSummary(datastore));
|
||||
const consumers = createMemo(() => consumerSummary(datastore));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getAlertStyles } from '@/utils/alerts';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
@@ -17,17 +17,19 @@ import {
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableMetricFallback,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableShell,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
filterPlatformResources,
|
||||
formatPlatformTableUptimeValue,
|
||||
getPlatformTableFiniteMetric,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
type PlatformResourceStatusFilter,
|
||||
type PlatformTableSortValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
PlatformResourceDetailToggleButton,
|
||||
@@ -50,6 +52,65 @@ import type { Resource } from '@/types/resource';
|
||||
// Per-host VM count is computed from the page scope client-side (no
|
||||
// extra API calls).
|
||||
|
||||
// Columns a user can sort by: every host column carries a scalar. VMs orders
|
||||
// on the per-host count computed from the page scope, so the getter takes the
|
||||
// count map alongside the row.
|
||||
const VSPHERE_HOST_SORT_KEYS = [
|
||||
'host',
|
||||
'version',
|
||||
'datacenter',
|
||||
'cluster',
|
||||
'power',
|
||||
'cpu',
|
||||
'memory',
|
||||
'datastores',
|
||||
'vms',
|
||||
'uptime',
|
||||
'vcenter',
|
||||
] as const;
|
||||
|
||||
type VsphereHostSortKey = (typeof VSPHERE_HOST_SORT_KEYS)[number];
|
||||
|
||||
const getVsphereHostSortValue = (
|
||||
host: Resource,
|
||||
vmCountByHost: Map<string, number>,
|
||||
key: VsphereHostSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
const meta = host.vmware;
|
||||
switch (key) {
|
||||
case 'host':
|
||||
return asTrimmedString(host.name) || host.id;
|
||||
case 'version':
|
||||
return asTrimmedString(host.agent?.osVersion) || null;
|
||||
case 'datacenter':
|
||||
return asTrimmedString(meta?.datacenterName) || null;
|
||||
case 'cluster':
|
||||
return asTrimmedString(meta?.clusterName) || null;
|
||||
case 'power':
|
||||
return asTrimmedString(formatVmwarePowerState(meta?.powerState)) || null;
|
||||
case 'cpu':
|
||||
return getPlatformTableFiniteMetric(host.cpu?.current) ?? null;
|
||||
case 'memory': {
|
||||
const total = getPlatformTableFiniteMetric(host.memory?.total) ?? 0;
|
||||
if (total > 0) {
|
||||
return ((getPlatformTableFiniteMetric(host.memory?.used) ?? 0) / total) * 100;
|
||||
}
|
||||
return getPlatformTableFiniteMetric(host.memory?.current) ?? null;
|
||||
}
|
||||
case 'datastores':
|
||||
return meta?.datastoreIds?.length ?? meta?.datastoreNames?.length ?? 0;
|
||||
case 'vms':
|
||||
return vmCountByHost.get(asTrimmedString(meta?.managedObjectId) || '') ?? 0;
|
||||
case 'uptime':
|
||||
return typeof host.uptime === 'number' && host.uptime > 0 ? host.uptime : null;
|
||||
case 'vcenter':
|
||||
return asTrimmedString(meta?.vcenterHost) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const VsphereHostsTable: Component<{
|
||||
hosts: Resource[];
|
||||
// Full vSphere scope so we can count VMs per host without spawning
|
||||
@@ -84,6 +145,17 @@ export const VsphereHostsTable: Component<{
|
||||
return map;
|
||||
});
|
||||
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'vsphereHosts',
|
||||
sortKeys: VSPHERE_HOST_SORT_KEYS,
|
||||
descendingFirst: ['cpu', 'memory', 'datastores', 'vms', 'uptime'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), (host, key) =>
|
||||
getVsphereHostSortValue(host, vmCountByHost(), key),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.hosts.length > 0}
|
||||
@@ -132,58 +204,99 @@ export const VsphereHostsTable: Component<{
|
||||
integer-count columns to what their content actually
|
||||
needs. Mobile widths are unchanged.
|
||||
*/}
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[16%]`}>
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="host"
|
||||
class="md:w-[16%]"
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[6%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="version"
|
||||
class="hidden md:table-cell md:w-[6%]"
|
||||
>
|
||||
Version
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="datacenter"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Datacenter
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="cluster"
|
||||
class="hidden md:table-cell md:w-[10%]"
|
||||
>
|
||||
Cluster
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="power"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
Power
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[12%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="cpu"
|
||||
class="md:w-[12%]"
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="metric-bar"
|
||||
sort={sort}
|
||||
sortKey="memory"
|
||||
class="md:w-[13%]"
|
||||
>
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="datastores"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
Datastores
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[4%]`}>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="vms"
|
||||
class="md:w-[4%]"
|
||||
>
|
||||
VMs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[6%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="uptime"
|
||||
class="hidden md:table-cell md:w-[6%]"
|
||||
>
|
||||
Uptime
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="vcenter"
|
||||
class="hidden md:table-cell md:w-[9%]"
|
||||
>
|
||||
vCenter
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(host) => {
|
||||
const meta = () => host.vmware;
|
||||
const name = () => asTrimmedString(host.name) || host.id;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { TableCell, TableRow } from '@/components/shared/Table';
|
||||
import { filterChipStatusDot } from '@/components/shared/FilterBar';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import {
|
||||
PlatformSortableTableHead,
|
||||
PlatformTableEmptyState,
|
||||
PlatformTableToolbar,
|
||||
createPlatformTableFilterState,
|
||||
createPlatformTableSortState,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
summarizePlatformTableValues,
|
||||
type PlatformTableFilterOption,
|
||||
type PlatformTableSortValue,
|
||||
PlatformTableShell,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import {
|
||||
@@ -75,6 +77,33 @@ const vmSummary = (resource: Resource): { label: string; title: string } =>
|
||||
const vmCount = (resource: Resource): number =>
|
||||
resource.vmware?.networkVmNames?.length ?? resource.vmware?.networkVmIds?.length ?? 0;
|
||||
|
||||
// Columns a user can sort by. Hosts and Connected VMs summarize several names
|
||||
// at once, so they carry no single scalar to order on.
|
||||
const VSPHERE_NETWORK_SORT_KEYS = ['network', 'type', 'vms', 'datacenter'] as const;
|
||||
|
||||
type VsphereNetworkSortKey = (typeof VSPHERE_NETWORK_SORT_KEYS)[number];
|
||||
|
||||
const getVsphereNetworkSortValue = (
|
||||
resource: Resource,
|
||||
key: VsphereNetworkSortKey,
|
||||
): PlatformTableSortValue => {
|
||||
switch (key) {
|
||||
case 'network':
|
||||
return networkName(resource);
|
||||
case 'type': {
|
||||
const type = networkType(resource);
|
||||
return type === '—' ? null : type;
|
||||
}
|
||||
case 'vms':
|
||||
return vmCount(resource);
|
||||
case 'datacenter':
|
||||
return asTrimmedString(resource.vmware?.datacenterName) || null;
|
||||
default:
|
||||
key satisfies never;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const VsphereNetworksTable: Component<{
|
||||
networks: Resource[];
|
||||
scope: Resource[];
|
||||
@@ -90,6 +119,14 @@ export const VsphereNetworksTable: Component<{
|
||||
});
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-network-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
|
||||
const sort = createPlatformTableSortState({
|
||||
storageKey: 'vsphereNetworks',
|
||||
sortKeys: VSPHERE_NETWORK_SORT_KEYS,
|
||||
descendingFirst: ['vms'],
|
||||
});
|
||||
const sortedRows = createMemo(() =>
|
||||
sort.sortRows(tableState.filtered(), getVsphereNetworkSortValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -132,37 +169,49 @@ export const VsphereNetworksTable: Component<{
|
||||
tableClass="min-w-full table-fixed text-xs md:min-w-[1040px]"
|
||||
header={
|
||||
<>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
|
||||
Network
|
||||
</TableHead>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[13%]`}>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
|
||||
<PlatformSortableTableHead
|
||||
kind="name"
|
||||
sort={sort}
|
||||
sortKey="network"
|
||||
class="md:w-[24%]"
|
||||
>
|
||||
Network
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="type"
|
||||
class="md:w-[13%]"
|
||||
>
|
||||
Type
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden md:table-cell md:w-[18%]">
|
||||
Hosts
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[7%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="numeric-value"
|
||||
sort={sort}
|
||||
sortKey="vms"
|
||||
class="hidden md:table-cell md:w-[7%]"
|
||||
>
|
||||
VMs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden lg:table-cell md:w-[18%]`}
|
||||
>
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead kind="text" sort={sort} class="hidden lg:table-cell md:w-[18%]">
|
||||
Connected VMs
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
|
||||
</PlatformSortableTableHead>
|
||||
<PlatformSortableTableHead
|
||||
kind="text"
|
||||
sort={sort}
|
||||
sortKey="datacenter"
|
||||
class="hidden md:table-cell md:w-[12%]"
|
||||
>
|
||||
Datacenter
|
||||
</TableHead>
|
||||
</PlatformSortableTableHead>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<For each={tableState.filtered()}>
|
||||
<For each={sortedRows()}>
|
||||
{(network) => {
|
||||
const hosts = createMemo(() => hostSummary(network));
|
||||
const vms = createMemo(() => vmSummary(network));
|
||||
|
||||
Reference in New Issue
Block a user