Say which TrueNAS services and VMs should be running

The Services tab gave a red dot to SMART with no reason, beside the same
Stopped pill as SSH, and spent a column on process IDs. The VMs tab spent
two columns on the bootloader and device counts. None of those columns
answers whether something is wrong.

A service stopped while set to start at boot, and a VM set to autostart
that is stopped, now say "should be running" beside the state, with the
full sentence on hover. Services also gain a drawer Condition row, since
the State column is hidden on phones. A VM stopped without autostart gets
no note, because someone stopped it on purpose. Process IDs, bootloader
and device counts stay in the drawers. Rows stay single-line, as the
shared platform-table rhythm requires.
This commit is contained in:
rcourtman
2026-10-05 06:13:52 +01:00
parent 94ac386abf
commit f30be8b48f
10 changed files with 389 additions and 108 deletions
@@ -2282,7 +2282,11 @@ not a replacement status card, CTA band, or page-local nested card.
inline detail rows, which remain content-sized. Summary cells must render
one line only: secondary identity, raw provider labels, and descriptive
context belong in an existing operational column, a supplemental tooltip,
or its inline detail drawer rather than a stacked subtitle. Text-only
or its inline detail drawer rather than a stacked subtitle. The same holds
for a note that explains a row's status dot: a TrueNAS service stopped while
set to start at boot says `should be running` beside its state in the State
cell, with the full sentence in the title and the drawer, not on a second
line under the name. Text-only
operational rows may use `getPlatformTableRowClass` to declare that same
shared rhythm explicitly; providers must not introduce local row-height
exceptions. Mobile truncation may rely on a full-value row
@@ -1685,6 +1685,14 @@ cannot create a browser mutation.
`TrueNASData.Services` on the canonical top-level `agent` resource,
preserving service name, boot enablement, runtime state, and process IDs
through clone, merge, transport, and frontend decode paths.
The TrueNAS Services and VMs tables lead with condition rather than
configuration. Process IDs, bootloader, and device counts stay in the row
drawer instead of taking table columns. A service stopped while set to
start at boot, and a stopped VM set to autostart, say `should be running`
in the State cell beside the raw state, with the full sentence in the
title and a drawer Condition row (the VM drawer summary says it too) because
State is hidden on phones. A stopped VM without autostart carries no note: it was stopped on
purpose. Rows stay single-line, per the shared platform-table rhythm.
Docker / Podman inventory extends that same canonical type contract beyond
generic workload rows and Swarm services. Runtime containers must preserve
Docker Engine container identity, owning host/runtime context, image,
@@ -0,0 +1,43 @@
{
"version": 1,
"base_sha": "94ac386abfe13a8eb0109ac1eea4c087390f9717",
"verified_at": "2026-10-05T05:13:41Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "27d50ea6c727d0d91ae34e2427e353abfa39a9306c665fc11d0be41a5fa2eabc",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "cbe56cad46b968c9238c70beb5e77f98cbaec9625935ad6ccda735b2ba5c6f43",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "b9e91b16bb7aeba6f2a02c544258babfd0b675171eac107a2f88622b2dadd5a7"
},
"routes": [
"/truenas/services",
"/truenas/vms"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Services at 1440: columns are Service, State, Boot, System. SMART reads Stopped plus red should be running in the State cell with the full sentence on hover, SSH (stopped, boot disabled) has no note, NFS and SMB unchanged. Before, SMART and SSH showed the same Stopped pill and a PIDs column took 20 percent",
"Services at 390: rows stay single-line with Service and Boot, and tapping SMART opens the drawer with Condition Should be running between State and Boot, where before the red dot had no reason anywhere on a phone",
"Service drawer still lists PIDs and PID count for SMB",
"VMs at 1440: columns are VM, State, CPU, Memory, Flags. ubuntu-build (stopped, no autostart) carries no note. Bootloader and device counts remain in the drawer under Platform details, Show TrueNAS",
"VMs at 390: VM, CPU and memory, single-line rows",
"The mock estate has no stopped VM set to autostart, so that note and its drawer Condition row are covered by TrueNASVirtualMachinesTable.test.tsx and resourceDetailDrawerTrueNASModel.test.ts rather than seen live"
],
"interactions": [
"Tapped the SMART row on a 390 phone and read the drawer",
"Expanded ubuntu-build and the Platform details TrueNAS section to confirm bootloader and devices remain",
"Expanded the SMB service drawer to confirm PIDs remain"
]
}
@@ -86,6 +86,52 @@ describe('resourceDetailDrawerTrueNASModel', () => {
]);
});
it('says a stopped VM set to autostart should be running', () => {
const stoppedOnPurpose = baseResource({
type: 'vm',
truenas: { vm: { name: 'ubuntu-build', state: 'STOPPED', vcpus: 2, autostart: false } },
});
const shouldRun = baseResource({
type: 'vm',
truenas: { vm: { name: 'router', state: 'STOPPED', vcpus: 2, autostart: true } },
});
expect(buildTrueNASDetailsSummary(stoppedOnPurpose)).toBe('Stopped, 2 vCPU');
expect(buildTrueNASDetailsSummary(shouldRun)).toBe('Stopped, should be running, 2 vCPU');
const compute = buildTrueNASDetailSections(shouldRun).find(
(section) => section.label === 'Compute',
);
expect(compute?.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: 'Condition', value: 'Should be running' }),
]),
);
const onPurposeCompute = buildTrueNASDetailSections(stoppedOnPurpose).find(
(section) => section.label === 'Compute',
);
expect(onPurposeCompute?.rows.map((row) => row.label)).not.toContain('Condition');
// The same rule as the VMs table: libvirt aliases, the resource status
// when TrueNAS sent no state, and no claim while TrueNAS data is stale.
const shutOff = baseResource({
type: 'vm',
truenas: { vm: { name: 'router', domainState: 'SHUTOFF', autostart: true } },
});
const statusOnly = baseResource({
type: 'vm',
status: 'offline',
truenas: { vm: { name: 'router', autostart: true } },
});
const stale = baseResource({
type: 'vm',
truenas: { vm: { name: 'router', state: 'STOPPED', autostart: true } },
platformData: { sourceStatus: { truenas: { status: 'stale' } } },
});
expect(buildTrueNASDetailsSummary(shutOff)).toContain('should be running');
expect(buildTrueNASDetailsSummary(statusOnly)).toContain('should be running');
expect(buildTrueNASDetailsSummary(stale)).not.toContain('should be running');
});
it('summarizes native app.query metadata for the detail drawer', () => {
const resource = baseResource({
type: 'app-container',
@@ -20,6 +20,7 @@ import {
type DetailSection,
type DetailValueTone,
} from '@/components/shared/detailSectionModel';
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone;
@@ -580,7 +581,25 @@ const formatVMTopology = (vm: ResourceTrueNASVMMeta): string | null => {
return null;
};
// Matches the stopped states in mapTrueNASVMStatus (features/truenas). A VM set
// to autostart that is stopped is the one stopped VM nobody chose, and the
// VMs table hides its State note on phones, so the drawer says it too.
const TRUENAS_VM_STOPPED_STATES = new Set(['stopped', 'shutoff', 'shutdown', 'poweroff']);
// Stale TrueNAS data may hold an old state, so the note waits for a current one.
const vmConditionLabel = (resource: Resource, vm: ResourceTrueNASVMMeta): string | null => {
if (hasImpairedResourceSource(resource, 'truenas')) return null;
if (!vm.autostart) return null;
const state = (asString(vm.state) ?? asString(vm.domainState))?.toLowerCase();
// Without a native state, the table falls back to the resource status.
const stopped = state
? TRUENAS_VM_STOPPED_STATES.has(state)
: resource.status === 'offline' || resource.status === 'stopped';
return stopped ? 'Should be running' : null;
};
const buildTrueNASVMSections = (
resource: Resource,
vm: ResourceTrueNASVMMeta,
): ResourceDetailDrawerTrueNASSection[] => {
const state = normalizeDelimitedLabel(vm.state);
@@ -592,6 +611,7 @@ const buildTrueNASVMSections = (
tone: (vm.state ?? vm.domainState)?.toLowerCase() === 'running' ? 'success' : 'warning',
}),
row('Domain state', sameState ? null : domainState),
row('Condition', vmConditionLabel(resource, vm), { tone: 'warning' }),
row('vCPU', formatVMCpu(vm)),
row('Topology', formatVMTopology(vm)),
row('Memory', formatDetailBytesValue(vm.memoryBytes)),
@@ -705,7 +725,7 @@ export const buildTrueNASDetailSections = (
resource: Resource,
): ResourceDetailDrawerTrueNASSection[] => {
if (resource.truenas?.share) return buildTrueNASShareSections(resource.truenas.share);
if (resource.truenas?.vm) return buildTrueNASVMSections(resource.truenas.vm);
if (resource.truenas?.vm) return buildTrueNASVMSections(resource, resource.truenas.vm);
if (resource.truenas?.app) return buildTrueNASAppSections(resource.truenas.app);
if (isTrueNASScopedResource(resource) && resource.storage) {
return buildTrueNASStorageSections(resource, resource.storage);
@@ -734,8 +754,10 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
const vm = resource.truenas?.vm;
if (vm) {
const deviceCount = asPositiveNumber(vm.deviceCount);
const condition = vmConditionLabel(resource, vm);
const summary = [
normalizeDelimitedLabel(vm.state ?? vm.domainState),
condition?.toLowerCase(),
formatVMCpu(vm),
formatDetailBytesValue(vm.memoryBytes),
deviceCount ? formatDetailCountValue(deviceCount, 'device') : null,
@@ -548,7 +548,7 @@ describe('platform overview layout guardrails', () => {
expect(truenasStorageTopologyTableSource).toContain('md:min-w-[960px]');
expect(truenasVirtualMachinesTableSource).toContain('md:min-w-[960px]');
expect(truenasVirtualMachinesTableSource).toMatch(
/class="hidden sm:table-cell md:w-\[19%\]"[\s\S]{0,120}?Flags/,
/class="hidden sm:table-cell md:w-\[26%\]"[\s\S]{0,120}?Flags/,
);
expect(vmwarePageSurfaceSource).toContain('<VsphereHostsTable');
expect(vmwarePageSurfaceSource).toContain('<VsphereAlertsTable');
@@ -3,6 +3,7 @@ import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableRow } from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
import {
PlatformWindowedRows,
PlatformSortableTableHead,
@@ -63,12 +64,50 @@ const formatServiceName = (value: string | undefined): string => {
return SERVICE_NAME_LABELS[normalized.toLowerCase()] ?? normalized;
};
const formatPIDs = (pids: number[] | undefined): { label: string; title: string } => {
const values = (pids ?? []).filter((pid) => Number.isFinite(pid) && pid > 0);
if (values.length === 0) return { label: '-', title: '' };
const visible = values.slice(0, 3).map(String);
const suffix = values.length > visible.length ? ` +${values.length - visible.length}` : '';
return { label: `${visible.join(', ')}${suffix}`, title: values.join(', ') };
// A service set to start at boot that is not running is the one state a red
// dot alone cannot explain, so the State cell says so beside the raw state.
// Rows stay single-line (the shared platform-table rhythm), so the full
// sentence rides on the title and in the drawer. PIDs are process detail
// nobody acts on from the list, so they live in the drawer too.
type ServiceStateNote = { short: string | null; full: string; tone: 'danger' | 'warning' };
const serviceStateNote = (
row: TrueNASServiceRow,
status: Exclude<TrueNASServiceStatusFilter, 'all'>,
): ServiceStateNote | null => {
if (status === 'stopped') {
return {
short: 'should be running',
full: 'Set to start at boot but not running',
tone: 'danger',
};
}
if (status !== 'attention') return null;
if (hasImpairedResourceSource(row.system, 'truenas')) {
return { short: 'not updated', full: 'TrueNAS has not updated this recently', tone: 'warning' };
}
const state = asTrimmedString(row.service.state);
return {
short: state ? null : 'not reported',
full: state
? `TrueNAS reports ${formatPlatformTableTitleCaseValue(state)}`
: 'TrueNAS has not reported a state',
tone: 'warning',
};
};
// The drawer value cell is one line on a phone, so it carries the short form
// with a capital, and the Boot row beside it says why it should be running.
const serviceConditionLabel = (row: TrueNASServiceRow): string | null => {
const note = serviceStateNote(row, mapTrueNASServiceStatus(row));
if (!note) return null;
if (!note.short) return note.full;
return note.short.charAt(0).toUpperCase() + note.short.slice(1);
};
const SERVICE_STATE_NOTE_CLASS: Record<ServiceStateNote['tone'], string> = {
danger: 'text-red-600 dark:text-red-300',
warning: 'text-amber-700 dark:text-amber-300',
};
const serviceStatusVariant = (
@@ -124,6 +163,9 @@ const buildServiceDetailSections = (row: TrueNASServiceRow): ServiceDetailSectio
detailRow('State', formatPlatformTableTitleCaseValue(row.service.state), {
tone: serviceTone(row),
}),
detailRow('Condition', serviceConditionLabel(row), {
tone: serviceTone(row),
}),
detailRow('Boot', detailBool(row.service.enabled), {
tone: row.service.enabled ? 'success' : 'muted',
}),
@@ -155,9 +197,8 @@ 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;
// Columns a user can sort by.
const TRUENAS_SERVICE_SORT_KEYS = ['service', 'state', 'boot', 'system'] as const;
type TrueNASServiceSortKey = (typeof TRUENAS_SERVICE_SORT_KEYS)[number];
@@ -172,12 +213,6 @@ const getTrueNASServiceSortValue = (
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:
@@ -202,7 +237,6 @@ export const TrueNASServicesTable: Component<{
const sort = createPlatformTableSortState({
storageKey: 'truenasServices',
sortKeys: TRUENAS_SERVICE_SORT_KEYS,
descendingFirst: ['pids'],
});
const sortedRows = createMemo(() =>
sort.sortRows(tableState.filtered(), getTrueNASServiceSortValue),
@@ -257,7 +291,7 @@ export const TrueNASServicesTable: Component<{
kind="name"
sort={sort}
sortKey="service"
class="platform-table-mobile-w-30 md:w-[24%]"
class="platform-table-mobile-w-30 md:w-[34%]"
>
Service
</PlatformSortableTableHead>
@@ -265,7 +299,7 @@ export const TrueNASServicesTable: Component<{
kind="badge"
sort={sort}
sortKey="state"
class="platform-table-phone-hidden md:w-[14%]"
class="platform-table-phone-hidden md:w-[16%]"
>
State
</PlatformSortableTableHead>
@@ -273,23 +307,15 @@ export const TrueNASServicesTable: Component<{
kind="badge"
sort={sort}
sortKey="boot"
class="platform-table-mobile-w-15 md:w-[14%]"
class="platform-table-mobile-w-15 md:w-[16%]"
>
Boot
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="pids"
class="platform-table-mobile-w-15 md:w-[20%]"
>
PIDs
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="system"
class="platform-table-phone-hidden md:w-[28%]"
class="platform-table-phone-hidden md:w-[34%]"
>
System
</PlatformSortableTableHead>
@@ -300,7 +326,7 @@ export const TrueNASServicesTable: Component<{
<PlatformWindowedRows items={sortedRows} estimatedRowHeight={32}>
{(row) => {
const status = () => mapTrueNASServiceStatus(row);
const pids = createMemo(() => formatPIDs(row.service.pids));
const note = () => serviceStateNote(row, status());
const rawState = () => asTrimmedString(row.service.state) || '-';
const detailRowId = () => detail.detailRowId(row);
const isExpanded = () => detail.isExpanded(row);
@@ -332,9 +358,27 @@ export const TrueNASServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} platform-table-phone-hidden`}
>
<span class="inline-flex rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-base-content">
{formatPlatformTableTitleCaseValue(rawState())}
</span>
<div
class="inline-flex max-w-full min-w-0 items-center gap-1.5"
title={note()?.full}
data-truenas-service-state-note={note() ? note()!.tone : undefined}
>
<span class="inline-flex shrink-0 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-base-content">
{formatPlatformTableTitleCaseValue(rawState())}
</span>
<Show when={note()?.short}>
{(short) => (
<span
class={`min-w-0 truncate text-[11px] font-medium ${SERVICE_STATE_NOTE_CLASS[note()!.tone]}`}
>
{short()}
</span>
)}
</Show>
<Show when={note()}>
{(current) => <span class="sr-only">{current().full}</span>}
</Show>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span
@@ -347,12 +391,6 @@ export const TrueNASServicesTable: Component<{
{row.service.enabled ? 'Enabled' : 'Disabled'}
</span>
</TableCell>
<TableCell
class={getPlatformTableCellClassForKind('text')}
title={pids().title || undefined}
>
<span class="tabular-nums text-base-content">{pids().label}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
>
@@ -362,7 +400,7 @@ export const TrueNASServicesTable: Component<{
<Show when={isExpanded()}>
<InlineDetailTableRow
cellId={detailRowId()}
colspan={5}
colspan={4}
data-inline-detail-for={row.id}
data-truenas-service-detail-row={row.id}
>
@@ -3,6 +3,7 @@ import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
import {
PlatformWindowedRows,
PlatformResponsiveTableLabel,
@@ -30,6 +31,7 @@ import type { Resource, ResourceTrueNASVMMeta } from '@/types/resource';
import {
filterTrueNASVMs,
getTrueNASResourceDisplayStatus,
mapTrueNASVMStatus,
type TrueNASVMStatusFilter,
} from './truenasPageModel';
@@ -60,29 +62,6 @@ const formatCPU = (vm: ResourceTrueNASVMMeta | undefined): string => {
return '-';
};
const formatDevices = (vm: ResourceTrueNASVMMeta | undefined): { label: string; title: string } => {
const counts = [
['disk', vm?.diskCount],
['nic', vm?.nicCount],
['display', vm?.displayCount],
['cdrom', vm?.cdromCount],
['usb', vm?.usbCount],
['pci', vm?.pciCount],
] as const;
const parts = counts
.filter(([, count]) => typeof count === 'number' && Number.isFinite(count) && count > 0)
.map(([kind, count]) => `${count} ${kind}`);
const total = vm?.deviceCount;
if (parts.length === 0) {
return typeof total === 'number' && total > 0
? { label: `${total} devices`, title: `${total} devices` }
: { label: '-', title: '' };
}
const visible = parts.slice(0, 2);
const suffix = parts.length > visible.length ? ` +${parts.length - visible.length}` : '';
return { label: `${visible.join(', ')}${suffix}`, title: parts.join(', ') };
};
const flagLabels = (vm: ResourceTrueNASVMMeta | undefined): string[] => {
const labels: string[] = [];
if (vm?.autostart) labels.push('Autostart');
@@ -92,11 +71,44 @@ 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;
// A VM set to autostart that is not running is the one stopped VM the user
// did not choose, so the State cell says so beside the raw state, as it does
// for a state TrueNAS flags. Rows stay single-line (the shared platform-table
// rhythm), so the full sentence rides on the title. Bootloader and device
// counts are configuration, not condition, so they live in the drawer.
type VMStateNote = { short: string | null; full: string; tone: 'danger' | 'warning' };
const vmStateNote = (resource: Resource): VMStateNote | null => {
const status = mapTrueNASVMStatus(resource);
if (status === 'stopped') {
return vmMeta(resource)?.autostart
? { short: 'should be running', full: 'Set to start at boot but stopped', tone: 'danger' }
: null;
}
if (status !== 'attention') return null;
if (hasImpairedResourceSource(resource, 'truenas')) {
return { short: 'not updated', full: 'TrueNAS has not updated this recently', tone: 'warning' };
}
const state = asTrimmedString(vmMeta(resource)?.state || vmMeta(resource)?.domainState);
return {
short: state ? null : 'not reported',
full: state
? `TrueNAS reports ${formatPlatformTableTitleCaseValue(state)}`
: 'TrueNAS has not reported a state',
tone: 'warning',
};
};
const VM_STATE_NOTE_CLASS: Record<VMStateNote['tone'], string> = {
danger: 'text-red-600 dark:text-red-300',
warning: 'text-amber-700 dark:text-amber-300',
};
// Columns a user can sort by. 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'] as const;
type TrueNASVMSortKey = (typeof TRUENAS_VM_SORT_KEYS)[number];
@@ -136,8 +148,6 @@ const getTrueNASVMSortValue = (
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;
@@ -215,7 +225,7 @@ export const TrueNASVirtualMachinesTable: Component<{
kind="name"
sort={sort}
sortKey="vm"
class="platform-table-mobile-w-30 md:w-[22%]"
class="platform-table-mobile-w-30 md:w-[34%]"
>
VM
</PlatformSortableTableHead>
@@ -223,7 +233,7 @@ export const TrueNASVirtualMachinesTable: Component<{
kind="badge"
sort={sort}
sortKey="state"
class="platform-table-phone-hidden md:w-[10%]"
class="platform-table-phone-hidden md:w-[14%]"
>
State
</PlatformSortableTableHead>
@@ -231,7 +241,7 @@ export const TrueNASVirtualMachinesTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="cpu"
class="platform-table-mobile-w-15 md:w-[10%]"
class="platform-table-mobile-w-15 md:w-[12%]"
>
CPU
</PlatformSortableTableHead>
@@ -239,29 +249,14 @@ export const TrueNASVirtualMachinesTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="memory"
class="platform-table-mobile-w-15 md:w-[10%]"
class="platform-table-mobile-w-15 md:w-[14%]"
>
<PlatformResponsiveTableLabel compact="Mem" full="Memory" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="boot"
class="platform-table-phone-hidden md:w-[11%]"
>
Boot
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="hidden md:table-cell md:w-[18%]"
>
Devices
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="hidden sm:table-cell md:w-[19%]"
class="hidden sm:table-cell md:w-[26%]"
>
Flags
</PlatformSortableTableHead>
@@ -281,7 +276,7 @@ export const TrueNASVirtualMachinesTable: Component<{
const indicator = () => getSimpleStatusIndicator(displayStatus());
const stateLabel = () =>
formatPlatformTableTitleCaseValue(vm()?.state || vm()?.domainState);
const devices = createMemo(() => formatDevices(vm()));
const note = () => vmStateNote(resource);
const flags = createMemo(() => flagLabels(vm()));
const detailRowId = () => drawer.detailRowId(resource);
const isExpanded = () => drawer.isExpanded(resource);
@@ -326,9 +321,27 @@ export const TrueNASVirtualMachinesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} platform-table-phone-hidden`}
>
<span class="text-[11px] font-medium text-base-content">
{stateLabel()}
</span>
<div
class="inline-flex max-w-full min-w-0 items-center gap-1.5"
title={note()?.full}
data-truenas-vm-state-note={note() ? note()!.tone : undefined}
>
<span class="shrink-0 text-[11px] font-medium text-base-content">
{stateLabel()}
</span>
<Show when={note()?.short}>
{(short) => (
<span
class={`min-w-0 truncate text-[11px] font-medium ${VM_STATE_NOTE_CLASS[note()!.tone]}`}
>
{short()}
</span>
)}
</Show>
<Show when={note()}>
{(current) => <span class="sr-only">{current().full}</span>}
</Show>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
@@ -340,17 +353,6 @@ export const TrueNASVirtualMachinesTable: Component<{
>
{formatPlatformTableBytesValue(vm()?.memoryBytes, '-')}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
>
{vm()?.bootloader || '-'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={devices().title}
>
<span class="truncate">{devices().label}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content sm:table-cell`}
title={flags().join(', ')}
@@ -367,7 +369,7 @@ export const TrueNASVirtualMachinesTable: Component<{
resource={resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
colSpan={5}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
/>
@@ -72,4 +72,55 @@ describe('TrueNASServicesTable', () => {
'false',
);
});
it('says why a service is red and keeps process IDs in the drawer', async () => {
const rows = buildTrueNASServiceRows([
makeSystem({
truenas: {
hostname: 'nas-primary',
services: [
{ id: '1', service: 'smb', enabled: true, state: 'RUNNING', pids: [2418] },
{ id: '2', service: 'smartd', enabled: true, state: 'STOPPED' },
{ id: '3', service: 'ssh', enabled: false, state: 'STOPPED' },
{ id: '4', service: 'nfs', enabled: true, state: 'CRASHED' },
],
},
} as Partial<Resource>),
]);
const { container } = render(() => (
<TrueNASServicesTable
services={rows}
emptyIcon={<span />}
emptyTitle="No services"
emptyDescription="No services"
showToolbar={false}
/>
));
const noteFor = (name: string) =>
screen.getByText(name).closest('tr')?.querySelector('[data-truenas-service-state-note]');
// Stopped while set to start at boot is the state the red dot alone cannot
// explain. The note sits in the State cell so the row stays single-line.
expect(noteFor('SMART')).toHaveTextContent('Stoppedshould be running');
expect(noteFor('SMART')).toHaveAttribute('title', 'Set to start at boot but not running');
expect(noteFor('SMART')).toHaveAttribute('data-truenas-service-state-note', 'danger');
// Stopped because boot start is off is a choice, not a fault.
expect(noteFor('SSH')).toBeNull();
expect(noteFor('SMB')).toBeNull();
expect(noteFor('NFS')).toHaveAttribute('title', 'TrueNAS reports Crashed');
expect(noteFor('NFS')).toHaveAttribute('data-truenas-service-state-note', 'warning');
const smartNameCell = screen.getByText('SMART').closest('td');
expect(smartNameCell).not.toHaveTextContent('should be running');
const headers = [...container.querySelectorAll('thead th')].map((th) => th.textContent?.trim());
expect(headers).toEqual(['Service', 'State', 'Boot', 'System']);
expect(container).not.toHaveTextContent('2418');
// Phones hide the State column, so the drawer carries the condition.
await fireEvent.click(screen.getByText('SMART').closest('tr')!);
const detail = within(screen.getByTestId('truenas-service-detail'));
expect(detail.getByText('Condition')).toBeInTheDocument();
expect(detail.getByText('Should be running')).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { TrueNASVirtualMachinesTable } from '@/features/truenas/TrueNASVirtualMachinesTable';
import type { Resource } from '@/types/resource';
const makeVM = (
id: string,
vm: NonNullable<NonNullable<Resource['truenas']>['vm']>,
overrides: Partial<Resource> = {},
): Resource =>
({
id,
type: 'vm',
name: id,
displayName: id,
status: vm.state === 'RUNNING' ? 'online' : 'offline',
platformType: 'truenas',
platformScopes: ['truenas'],
truenas: { vm: { name: id, ...vm } },
...overrides,
}) as Resource;
afterEach(() => {
cleanup();
});
describe('TrueNASVirtualMachinesTable', () => {
it('flags only the stopped VMs nobody chose to stop and keeps configuration in the drawer', () => {
const vms = [
makeVM('windows-lab', {
state: 'RUNNING',
autostart: true,
bootloader: 'UEFI',
diskCount: 1,
nicCount: 1,
}),
makeVM('ubuntu-build', { state: 'STOPPED', bootloader: 'UEFI', diskCount: 1 }),
makeVM('router', { state: 'STOPPED', autostart: true, bootloader: 'UEFI' }),
makeVM('lab-paused', { state: 'PAUSED' }),
];
const { container } = render(() => (
<TrueNASVirtualMachinesTable
vms={vms}
scope={vms}
emptyIcon={<span />}
emptyTitle="No VMs"
emptyDescription="No VMs"
showToolbar={false}
/>
));
const noteFor = (name: string) =>
screen.getByText(name).closest('tr')?.querySelector('[data-truenas-vm-state-note]');
expect(noteFor('router')).toHaveTextContent('Stoppedshould be running');
expect(noteFor('router')).toHaveAttribute('title', 'Set to start at boot but stopped');
expect(noteFor('ubuntu-build')).toBeNull();
expect(noteFor('windows-lab')).toBeNull();
expect(noteFor('lab-paused')).toHaveAttribute('title', 'TrueNAS reports Paused');
const headers = [...container.querySelectorAll('thead th')].map((th) => th.textContent?.trim());
expect(headers).not.toContain('Boot');
expect(headers).not.toContain('Devices');
expect(container.querySelector('tbody')).not.toHaveTextContent('UEFI');
});
});