Add native TrueNAS VM inventory to overview

Project TrueNAS vm.query inventory into canonical VM resources and render it on the TrueNAS overview.
This commit is contained in:
rcourtman 2026-05-20 22:32:46 +01:00
parent 2202f36674
commit 3fa46aa836
34 changed files with 1280 additions and 48 deletions

View file

@ -483,6 +483,8 @@ On your TrueNAS system:
| Data | Where it appears |
|---|---|
| System info (CPU, memory, uptime) | Infrastructure page |
| Virtual machines | TrueNAS Overview |
| Apps | TrueNAS Overview |
| ZFS Pools & datasets | Storage page |
| Physical disks | Storage page |
| ZFS Snapshots | Recovery page |

View file

@ -45,7 +45,7 @@ See [VM Disk Monitoring](VM_DISK_MONITORING.md) for details.
Yes! If Pulse detects Ceph storage, it automatically queries cluster health, OSD status, and pool usage. No extra config needed.
### Does Pulse monitor TrueNAS?
Yes! Pulse v6 includes first-class TrueNAS SCALE/CORE integration. Add your TrueNAS server in **Settings → TrueNAS** with the URL and API key. Pulse monitors pools, datasets, disks, ZFS snapshots, replication tasks, and alerts. TrueNAS resources appear in the unified Infrastructure, Storage, and Recovery views.
Yes. Pulse v6 includes first-class TrueNAS SCALE/CORE integration. Add your TrueNAS server in **Settings → TrueNAS** with the URL and API key. Pulse monitors the appliance, native VMs, apps, pools, datasets, disks, ZFS snapshots, replication tasks, and alerts. TrueNAS resources appear in the TrueNAS, Infrastructure, Storage, and Recovery views.
### Where did my pages go? (Unified Navigation)
Pulse v6 organises the UI by **task** instead of **platform**:

View file

@ -18,13 +18,15 @@ On your TrueNAS system:
2. Click **Add** and create a new key.
3. Copy the key value and paste it into Pulse.
> **Tip**: A read-only key is sufficient for monitoring. Pulse does not write to TrueNAS.
> **Tip**: A read-only key is sufficient for monitoring. Native app control actions require a key with the corresponding TrueNAS app permissions.
## What Gets Monitored
| Data | Unified Page | Details |
|---|---|---|
| System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status |
| Virtual machines | TrueNAS Overview | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API |
| Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details |
| ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) |
| ZFS Datasets | Storage | Used/available space, mount status, read-only flag |
| Physical Disks | Storage | Model, serial, size, transport type, rotational flag |
@ -37,6 +39,8 @@ On your TrueNAS system:
TrueNAS resources are mapped into the unified resource model:
- **TrueNAS host** → appears as a resource with `source: truenas` on the **Infrastructure** page.
- **TrueNAS VMs** → appear as canonical `vm` workloads on the **TrueNAS** page.
- **TrueNAS apps** → appear as canonical `app-container` workloads on the **TrueNAS** page.
- **ZFS pools and datasets** → appear on the **Storage** page.
- **ZFS snapshots and replication** → appear on the **Recovery** page as recovery points.
- **TrueNAS alerts** → surfaced on the **Alerts** page alongside Proxmox and other platform alerts.

View file

@ -262,6 +262,7 @@
],
"canonical_projections": [
"agent",
"vm",
"app-container",
"storage",
"physical-disk"

View file

@ -308,7 +308,7 @@ Support floor fields are recorded in this order: `setup`, `visibility`,
| `proxmox-pve` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=augmentation-only` |
| `proxmox-pbs` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pbs`, `storage` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
| `proxmox-pmg` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pmg` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=n/a`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
| `truenas` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `app-container`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `truenas` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `app-container`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `vmware-vsphere` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `storage` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
Presentation-only platform vocabulary uses `governance=presentation-only`,
@ -368,7 +368,7 @@ or runtime platform fallbacks that are not declared in this document.
| `proxmox-pve` | platform | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` |
| `proxmox-pbs` | platform | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` |
| `proxmox-pmg` | platform | Proxmox | api-backed | none today | `pmg` |
| `truenas` | platform | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `app-container`, `storage`, `physical-disk` |
| `truenas` | platform | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `vm`, `app-container`, `storage`, `physical-disk` |
| `vmware-vsphere` | platform | VMware | api-backed | host or guest agent later, not phase 1 | `agent`, `vm`, `storage` |
| Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control |

View file

@ -493,11 +493,12 @@ declared floor.
1. Architecture boundary:
TrueNAS is API-first. The unified agent may augment a TrueNAS system later,
but it is not required for bootstrap or baseline support. TrueNAS must
project into the canonical `agent`, `app-container`, `storage`,
project into the canonical `agent`, `vm`, `app-container`, `storage`,
`physical-disk`, and recovery contracts. TrueNAS app rows may carry a
TrueNAS-native app facet sourced from `app.query` / `active_workloads` so
the platform page can render API-native app facts without turning Docker
labels into the TrueNAS product model.
TrueNAS-native app facet sourced from `app.query` / `active_workloads`, and
native VM rows may carry a TrueNAS VM facet sourced from `vm.query`, so the
platform page can render API-native app and VM facts without turning Docker
labels or generic guest assumptions into the TrueNAS product model.
2. Onboarding path:
Supported now through the shared platform-connections flow and
`/api/truenas/connections`. Operators can add, test, edit, retest, and
@ -514,11 +515,13 @@ declared floor.
separate `truenas-system` surface or provider-local infrastructure model.
4. Workloads:
Supported now for TrueNAS apps projected as canonical `app-container`
workloads with shared workload navigation, metrics, and related links. The
TrueNAS platform overview renders those apps through the TrueNAS API-native
app facet first, using Docker metadata only as runtime compatibility
fallback. Out of scope: a parallel provider-local workload control plane
that bypasses the canonical resource/action contracts.
workloads and native TrueNAS virtual machines projected as canonical `vm`
workloads. The TrueNAS platform overview renders those workloads through
the TrueNAS API-native facets first, using Docker metadata only as runtime
compatibility fallback for apps. Out of scope: a parallel provider-local
workload control plane that bypasses the canonical resource/action
contracts, or VM lifecycle control before it lands on the governed action
path.
5. Storage and disk health:
Supported now for pools, datasets, and physical disks projected into the
shared storage and disk contracts, including SMART/disk-state risk, live

View file

@ -222,6 +222,11 @@ work extends shared components instead of creating new local variants.
9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
It must expose the manifest `surface_kind` field so runtime lenses such as
`docker` are not collapsed back into owning platform semantics.
It must also preserve canonical projection lists from the governed manifest
without page-local narrowing; for example, TrueNAS exposes both native
`vm` workloads and `app-container` workloads through the same generated
platform projection used by route helpers, badges, source filters, and
type unions.
10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary.
That shared boundary must preserve `availability` as the agentless
infrastructure source for `network-endpoint` resources and settings

View file

@ -916,6 +916,14 @@ telemetry. `internal/truenas/client.go`, `internal/truenas/provider.go`, and
canonical `app-container` metrics contract, and sync them into the existing
guest metrics-history/store path. Pulse must not add a TrueNAS-only charts
lane for that telemetry.
That same monitoring boundary now also owns native TrueNAS VM inventory.
`internal/truenas/client.go` must ingest `vm.query` through the official
`/api/current` JSON-RPC websocket transport, `internal/truenas/provider.go`
must project those rows as canonical `vm` resources under the top-level
TrueNAS appliance, and frontend TrueNAS surfaces must read the typed
`TrueNASData.VM` facet instead of inventing a provider-local VM table contract.
Pulse must not treat TrueNAS VMs as Proxmox guests, Docker containers, or a
separate `truenas-vm` resource type.
That same monitoring boundary now also owns connected-infrastructure
projection for API-backed platforms. `internal/monitoring/connected_infrastructure.go`
must project TrueNAS into the canonical connected-infrastructure surface list,
@ -984,10 +992,11 @@ contract. Pulse must not treat TrueNAS CPU temperature as an agent-only
capability or invent a TrueNAS-local sensor payload.
Taken together, this is the current monitoring-owned TrueNAS floor: one stored
API connection can surface one canonical top-level system, shared host
telemetry/history, app-container workloads, disk health/history, and
per-connection poll health without requiring the unified agent. The same
poller/provider path also owns assistant-driven app start/stop, logs, and
config refresh for those canonical workloads. Pulse does not promise a
telemetry/history, app-container workloads, native VM workloads, disk
health/history, and per-connection poll health without requiring the unified
agent. The same poller/provider path also owns assistant-driven app
start/stop, logs, and config refresh for canonical app workloads. Pulse does
not promise a
separate TrueNAS runtime model, broader NAS administration, or agent-required
bootstrap at this floor.
That same monitoring boundary now also owns VMware signal enrichment on the

View file

@ -144,12 +144,13 @@ services, including mock-mode config/data/log path examples. That context is
read-only evidence: it must not become a storage/recovery-owned command path,
secret source, restore entitlement, or frontend-only fixture separate from the
canonical `/api/discovery` payload.
Storage and recovery may also consume unified-resource TrueNAS app metadata as
read-only workload context when explaining appliance-owned storage or protected
items. `TrueNASData.App` remains unified-resource/platform truth sourced from
the TrueNAS API; storage/recovery must not reinterpret app containers, volumes,
or update posture as storage ownership, restore entitlement, or a separate
Docker-only inventory path.
Storage and recovery may also consume unified-resource TrueNAS app and VM
metadata as read-only workload context when explaining appliance-owned storage
or protected items. `TrueNASData.App` and `TrueNASData.VM` remain
unified-resource/platform truth sourced from the TrueNAS API; storage/recovery
must not reinterpret app containers, VM device inventory, volumes, or update
posture as storage ownership, restore entitlement, or a separate Docker-only
or TrueNAS-local inventory path.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`

View file

@ -1170,6 +1170,12 @@ lane-local workload UI. API-backed `app.stats` telemetry must now project onto
that same canonical `app-container` contract as live metrics and metrics
targets, using the shared `docker:<id>` in-memory key and `dockerContainer`
history store path instead of adding a TrueNAS-local workload history lane.
TrueNAS-managed virtual machines follow the same canonical workload rule. One
TrueNAS VM from `vm.query` must project as one canonical `vm` resource under
`SourceTrueNAS`, carrying native `TrueNASData.VM` metadata instead of inventing
a `truenas-vm` resource type, borrowing Proxmox guest fields, or rendering a
provider-local VM contract. Native VM lifecycle/control is read-only at this
floor until a governed action path owns it.
TrueNAS top-level systems now follow the same canonical host rule. One
TrueNAS appliance must project as one canonical `agent` resource under
`SourceTrueNAS`, carrying host-facing `AgentData` plus provider-specific
@ -1193,10 +1199,10 @@ inventing a provider-local temperature payload or leaving TrueNAS host
temperatures unavailable without the unified agent.
AI discovery and query surfaces now follow the same rule. Assistant runtime
paths such as `pulse_query` and unified AI context must expose TrueNAS-backed
canonical `agent`, `app-container`, `storage`, and `physical-disk` resources
through those shared contracts, filtering dataset-topology storage children
out of any `storage-pool` presentation instead of inventing TrueNAS-local
assistant types or mislabeling datasets as pools.
canonical `agent`, `vm`, `app-container`, `storage`, and `physical-disk`
resources through those shared contracts, filtering dataset-topology storage
children out of any `storage-pool` presentation instead of inventing
TrueNAS-local assistant types or mislabeling datasets as pools.
That same canonical app-container rule now also governs diagnostics. Assistant
runtime paths such as `pulse_read` must resolve API-backed TrueNAS apps
through the shared canonical `app-container` identity and `resource_id`
@ -1209,7 +1215,7 @@ and `resource_id` contract, then project native runtime/config shape through
the shared app-container payload rather than forcing those resources through
guest-config routing or inventing a TrueNAS-local config type.
That projection contract is the product-definition floor for TrueNAS support:
Pulse supports TrueNAS only when it lands on the shared `agent`,
Pulse supports TrueNAS only when it lands on the shared `agent`, `vm`,
`app-container`, `storage`, `physical-disk`, and recovery-linked resource
shapes. The unified agent may augment a TrueNAS system later, but baseline
support does not depend on it, and product surfaces must not reopen a parallel

View file

@ -17,6 +17,7 @@ import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplication
import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw';
import truenasPageSurfaceSource from '@/features/truenas/TrueNASPageSurface.tsx?raw';
import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw';
import truenasVirtualMachinesTableSource from '@/features/truenas/TrueNASVirtualMachinesTable.tsx?raw';
import vmwarePageSurfaceSource from '@/features/vmware/VmwarePageSurface.tsx?raw';
import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw';
@ -28,6 +29,7 @@ const platformTableSources = [
kubernetesNodesTableSource,
kubernetesDeploymentsTableSource,
truenasSystemsTableSource,
truenasVirtualMachinesTableSource,
vsphereHostsTableSource,
];
@ -155,6 +157,7 @@ describe('platform overview layout guardrails', () => {
expect(kubernetesPageSurfaceSource).toContain('<KubernetesNodesTable');
expect(kubernetesPageSurfaceSource).toContain('<KubernetesDeploymentsTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASSystemsTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASVirtualMachinesTable');
expect(truenasPageSurfaceSource).toContain('<TrueNASAppsTable');
expect(truenasPageSurfaceSource).toContain('<StorageSurface');
expect(truenasPageSurfaceSource).toContain('suppressNodeFilter');

View file

@ -11,6 +11,7 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import { TrueNASAppsTable } from './TrueNASAppsTable';
import { TrueNASSystemsTable } from './TrueNASSystemsTable';
import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
@ -23,7 +24,7 @@ import {
// first-class type tokens and including them triggers a 400 from
// `/api/resources`. The page model still buckets by topology
// client-side.
const TRUENAS_RESOURCE_QUERY = 'type=agent,app-container,storage,physical_disk';
const TRUENAS_RESOURCE_QUERY = 'type=agent,vm,app-container,storage,physical_disk';
const TRUENAS_PLATFORM_FILTER = 'truenas';
const VALID_TABS = new Set<TrueNASPageTabId>(TRUENAS_TAB_SPECS.map((tab) => tab.id));
@ -116,6 +117,15 @@ function TrueNASOverview(props: TrueNASOverviewProps) {
emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance."
showToolbar={false}
/>
<Show when={props.model().vms.length > 0}>
<TrueNASVirtualMachinesTable
vms={props.model().vms}
scope={props.model().resources}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS VMs"
emptyDescription="VMs appear here once the TrueNAS API reports vm.query inventory."
/>
</Show>
<Show when={props.model().apps.length > 0}>
<TrueNASAppsTable
apps={props.model().apps}

View file

@ -0,0 +1,302 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformTableFilterOption,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource, ResourceTrueNASVMMeta } from '@/types/resource';
import { filterTrueNASVMs, type TrueNASVMStatusFilter } from './truenasPageModel';
const TRUENAS_VM_STATUS_OPTIONS: PlatformTableFilterOption<TrueNASVMStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running', tone: 'success' },
{ value: 'attention', label: 'Attention', tone: 'warning' },
{ value: 'stopped', label: 'Stopped', tone: 'danger' },
];
const vmMeta = (resource: Resource): ResourceTrueNASVMMeta | undefined => resource.truenas?.vm;
const titleCase = (value: string | undefined): string => {
const normalized = asTrimmedString(value);
if (!normalized) return 'Unknown';
return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase();
};
const formatBytes = (bytes: number | undefined): string => {
if (!bytes || bytes <= 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIdx = 0;
while (value >= 1024 && unitIdx < units.length - 1) {
value /= 1024;
unitIdx += 1;
}
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`;
};
const formatCPU = (vm: ResourceTrueNASVMMeta | undefined): string => {
const vcpus = vm?.vcpus;
if (typeof vcpus === 'number' && Number.isFinite(vcpus) && vcpus > 0) return `${vcpus} vCPU`;
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}c / ${threads}t`;
}
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');
if (vm?.secureBoot) labels.push('Secure boot');
if (vm?.trustedPlatformModule) labels.push('TPM');
if (vm?.suspendOnSnapshot) labels.push('Suspend');
return labels;
};
export const TrueNASVirtualMachinesTable: Component<{
vms: Resource[];
scope: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.vms,
initialStatus: 'all' as TrueNASVMStatusFilter,
filter: filterTrueNASVMs,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-vm-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
return (
<Show
when={props.vms.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS VMs"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_VM_STATUS_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="VMs"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No VMs match current filters"
description="Adjust the search or status filter to see more TrueNAS VMs."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Virtual Machines" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1100px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
VM
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[10%]`}>
State
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
>
CPU
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden sm:table-cell md:w-[10%]`}
>
Memory
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[11%]`}
>
Boot
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Devices
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[19%]`}>
Flags
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(resource) => {
const vm = () => vmMeta(resource);
const name = () =>
asTrimmedString(vm()?.name) ||
asTrimmedString(resource.displayName) ||
asTrimmedString(resource.name) ||
resource.id;
const indicator = () => getSimpleStatusIndicator(resource.status);
const stateLabel = () => titleCase(vm()?.state || vm()?.domainState);
const devices = createMemo(() => formatDevices(vm()));
const flags = createMemo(() => flagLabels(vm()));
const detailRowId = () => drawer.detailRowId(resource);
const isExpanded = () => drawer.isExpanded(resource);
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-truenas-vm-row={resource.id}
onClick={() => drawer.toggle(resource)}
onKeyDown={drawer.handleActivationKey(resource)}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={indicator().label}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
{name()}
</div>
<div class="truncate text-[10px] text-muted">
{vm()?.description || vm()?.uuid || resource.parentName || 'TrueNAS'}
</div>
</div>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class="text-[11px] font-medium text-base-content">
{stateLabel()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content sm:table-cell`}
>
{formatCPU(vm())}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content sm:table-cell`}
>
{formatBytes(vm()?.memoryBytes)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{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')} text-base-content`}
title={flags().join(', ')}
>
<Show
when={flags().length > 0}
fallback={<span class="text-muted">-</span>}
>
<span class="truncate">{flags().slice(0, 2).join(', ')}</span>
</Show>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default TrueNASVirtualMachinesTable;

View file

@ -4,7 +4,9 @@ import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
filterTrueNASApps,
filterTrueNASVMs,
mapTrueNASAppStatus,
mapTrueNASVMStatus,
} from '../truenasPageModel';
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource => ({
@ -26,6 +28,7 @@ describe('truenasPageModel', () => {
it('buckets systems and apps while keeping storage inventory in scope for shared surfaces', () => {
const model = buildTrueNASPageModel([
makeResource({ id: 'truenas-system', type: 'agent' }),
makeResource({ id: 'truenas-vm', type: 'vm' }),
makeResource({ id: 'truenas-app', type: 'app-container' }),
makeResource({ id: 'truenas-pool', type: 'pool' }),
makeResource({ id: 'truenas-disk', type: 'physical_disk' }),
@ -34,9 +37,10 @@ describe('truenasPageModel', () => {
]);
expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']);
expect(model.vms.map((r) => r.id)).toEqual(['truenas-vm']);
expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']);
expect(model.resources.map((r) => r.id).sort()).toEqual(
['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system'].sort(),
['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system', 'truenas-vm'].sort(),
);
});
@ -87,4 +91,45 @@ describe('truenasPageModel', () => {
filterTrueNASApps([nextcloud, adguard], 'adguardhome', 'stopped').map((r) => r.id),
).toEqual(['app-adguard']);
});
it('filters VMs using native TrueNAS vm.query metadata', () => {
const windows = makeResource({
id: 'vm-windows',
type: 'vm',
truenas: {
hostname: 'truenas-main',
vm: {
id: '42',
name: 'windows-lab',
description: 'Build validation workstation',
state: 'RUNNING',
bootloader: 'UEFI',
cpuMode: 'HOST-PASSTHROUGH',
uuid: 'vm-uuid-1',
},
},
});
const ubuntu = makeResource({
id: 'vm-ubuntu',
type: 'vm',
status: 'offline',
truenas: {
vm: {
id: '43',
name: 'ubuntu-build',
state: 'STOPPED',
machineType: 'q35',
},
},
});
expect(mapTrueNASVMStatus(windows)).toBe('running');
expect(mapTrueNASVMStatus(ubuntu)).toBe('stopped');
expect(filterTrueNASVMs([windows, ubuntu], 'passthrough', 'running').map((r) => r.id)).toEqual([
'vm-windows',
]);
expect(filterTrueNASVMs([windows, ubuntu], 'q35', 'stopped').map((r) => r.id)).toEqual([
'vm-ubuntu',
]);
});
});

View file

@ -3,6 +3,7 @@ import type { Resource, ResourceType } from '@/types/resource';
export type TrueNASPageTabId = 'overview' | 'storage';
export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped';
export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped';
export type TrueNASTabSpec = {
id: TrueNASPageTabId;
@ -10,10 +11,11 @@ export type TrueNASTabSpec = {
path: string;
};
// The Overview tab is intentionally narrow: appliance systems first, then apps
// when present. Storage inventory, pool topology, and physical disks all live
// on the Storage tab so operators have one canonical storage surface instead
// of a duplicated overview snapshot plus a richer storage page.
// The Overview tab is intentionally narrow: appliance systems first, then
// native workload inventory when present. Storage inventory, pool topology, and
// physical disks all live on the Storage tab so operators have one canonical
// storage surface instead of a duplicated overview snapshot plus a richer
// storage page.
export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
{ id: 'overview', label: 'Overview', path: '/truenas/overview' },
{ id: 'storage', label: 'Storage', path: '/truenas/storage' },
@ -21,6 +23,7 @@ export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
const TRUENAS_RESOURCE_TYPES = new Set<ResourceType>([
'agent',
'vm',
'app-container',
'storage',
'pool',
@ -34,6 +37,7 @@ const isTrueNASPlatform = (resource: Resource): boolean =>
export type TrueNASPageModel = {
resources: Resource[];
systems: Resource[];
vms: Resource[];
apps: Resource[];
};
@ -43,10 +47,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
);
const systems = trueNasResources.filter((resource) => resource.type === 'agent');
const vms = trueNasResources.filter((resource) => resource.type === 'vm');
const apps = trueNasResources.filter((resource) => resource.type === 'app-container');
return {
resources: trueNasResources,
systems,
vms,
apps,
};
}
@ -66,6 +72,21 @@ export function mapTrueNASAppStatus(resource: Resource): Exclude<TrueNASAppStatu
return 'attention';
}
export function mapTrueNASVMStatus(resource: Resource): Exclude<TrueNASVMStatusFilter, 'all'> {
const state = normalize(resource.truenas?.vm?.state || resource.truenas?.vm?.domainState);
if (state === 'running' || state === 'active') return 'running';
if (state === 'stopped' || state === 'shutoff' || state === 'shutdown' || state === 'poweroff') {
return 'stopped';
}
if (state === 'paused' || state === 'suspended' || state === 'error' || state === 'crashed') {
return 'attention';
}
if (resource.status === 'online' || resource.status === 'running') return 'running';
if (resource.status === 'offline' || resource.status === 'stopped') return 'stopped';
return 'attention';
}
const portSearchTokens = (resource: Resource): string[] => {
const app = resource.truenas?.app;
const tokens: string[] = [];
@ -139,3 +160,45 @@ export function filterTrueNASApps(
return appSearchHaystack(app).includes(needle);
});
}
const vmSearchHaystack = (resource: Resource): string => {
const vm = resource.truenas?.vm;
return [
resource.name,
resource.displayName,
resource.id,
resource.parentName,
resource.platformId,
resource.platformType,
resource.truenas?.hostname,
vm?.id,
vm?.name,
vm?.description,
vm?.state,
vm?.domainState,
vm?.cpuMode,
vm?.cpuModel,
vm?.bootloader,
vm?.time,
vm?.archType,
vm?.machineType,
vm?.uuid,
...(resource.tags ?? []),
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
};
export function filterTrueNASVMs(
vms: Resource[],
search: string,
status: TrueNASVMStatusFilter,
): Resource[] {
const needle = normalize(search);
return vms.filter((vm) => {
if (status !== 'all' && mapTrueNASVMStatus(vm) !== status) return false;
if (!needle) return true;
return vmSearchHaystack(vm).includes(needle);
});
}

View file

@ -22,6 +22,7 @@ import {
import {
ADMITTED_PLATFORM_IDS,
PRESENTATION_ONLY_PLATFORM_IDS,
SOURCE_PLATFORM_CANONICAL_PROJECTIONS,
SUPPORTED_PLATFORM_IDS,
} from '@/utils/platformSupportManifest.generated';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
@ -50,6 +51,16 @@ describe('Resource Type Guards', () => {
}
});
it('keeps TrueNAS native VM support in the governed platform projection', () => {
expect(SOURCE_PLATFORM_CANONICAL_PROJECTIONS.truenas).toEqual([
'agent',
'vm',
'app-container',
'storage',
'physical-disk',
]);
});
describe('isInfrastructure', () => {
const infrastructureTypes: ResourceType[] = [
'agent',

View file

@ -651,6 +651,39 @@ export interface ResourceTrueNASAppMeta {
stats?: ResourceTrueNASAppStats;
}
export interface ResourceTrueNASVMMeta {
id?: string;
name?: string;
description?: string;
state?: string;
domainState?: string;
pid?: number;
vcpus?: number;
cores?: number;
threads?: number;
memoryBytes?: number;
minMemoryBytes?: number;
cpuMode?: string;
cpuModel?: string;
bootloader?: string;
autostart?: boolean;
suspendOnSnapshot?: boolean;
trustedPlatformModule?: boolean;
secureBoot?: boolean;
time?: string;
archType?: string;
machineType?: string;
uuid?: string;
displayAvailable?: boolean;
deviceCount?: number;
diskCount?: number;
nicCount?: number;
displayCount?: number;
cdromCount?: number;
usbCount?: number;
pciCount?: number;
}
export interface ResourceTrueNASMeta {
hostname?: string;
version?: string;
@ -663,6 +696,7 @@ export interface ResourceTrueNASMeta {
rebuildInProgress?: boolean;
rebuildSummary?: string;
app?: ResourceTrueNASAppMeta;
vm?: ResourceTrueNASVMMeta;
}
export interface ResourceKubernetesMetricCapabilities {

View file

@ -1,11 +1,11 @@
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
// Do not edit by hand.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34
// Source SHA256: 921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34',
sha256: '921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 2,
@ -199,7 +199,7 @@ export const PLATFORM_SUPPORT_MANIFEST = {
readinessStage: 'supported',
primaryMode: 'api-backed',
onboardingPaths: ['platform-connections'],
canonicalProjections: ['agent', 'app-container', 'storage', 'physical-disk'],
canonicalProjections: ['agent', 'vm', 'app-container', 'storage', 'physical-disk'],
supportFloor: {
setup: 'supported',
visibility: 'supported',
@ -625,7 +625,7 @@ export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = {
'proxmox-pve': ['agent', 'vm', 'system-container', 'storage', 'ceph', 'physical-disk'],
'proxmox-pbs': ['pbs', 'storage'],
'proxmox-pmg': ['pmg'],
truenas: ['agent', 'app-container', 'storage', 'physical-disk'],
truenas: ['agent', 'vm', 'app-container', 'storage', 'physical-disk'],
'vmware-vsphere': ['agent', 'vm', 'storage'],
unraid: [],
'synology-dsm': [],

View file

@ -3419,6 +3419,66 @@ func TestResourceListIncludesTrueNASAppsAsAppContainers(t *testing.T) {
}
}
func TestResourceListIncludesTrueNASVMsAsCanonicalWorkloads(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() {
truenas.SetFeatureEnabled(previous)
})
fixtures := truenas.DefaultFixtures()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: truenas.NewProvider(fixtures).Records(),
ownedSources: []unified.DataSource{unified.SourceTrueNAS},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=vm&source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != len(fixtures.VMs) {
t.Fatalf("expected %d TrueNAS VM resources, got %d", len(fixtures.VMs), len(resp.Data))
}
var windows *unified.Resource
for i := range resp.Data {
resource := &resp.Data[i]
if resource.Name == "windows-lab" {
windows = resource
break
}
}
if windows == nil {
t.Fatalf("expected windows-lab in TrueNAS VM response, got %+v", resp.Data)
}
if !containsSource(windows.Sources, unified.SourceTrueNAS) {
t.Fatalf("expected TrueNAS source, got %+v", windows.Sources)
}
if windows.ParentName != "truenas-main" {
t.Fatalf("expected windows-lab parentName truenas-main, got %q", windows.ParentName)
}
if windows.TrueNAS == nil || windows.TrueNAS.VM == nil {
t.Fatalf("expected native VM payload on TrueNAS VM resource, got %+v", windows)
}
if windows.TrueNAS.VM.ID != "42" || windows.TrueNAS.VM.VCPUs != 4 || windows.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 {
t.Fatalf("unexpected native TrueNAS VM payload: %+v", windows.TrueNAS.VM)
}
if windows.MetricsTarget == nil || windows.MetricsTarget.ResourceType != "vm" || windows.MetricsTarget.ResourceID != "42" {
t.Fatalf("expected canonical TrueNAS VM metrics target 42, got %+v", windows.MetricsTarget)
}
}
func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)

View file

@ -1,7 +1,7 @@
// Code generated by scripts/release_control/generate_platform_support_backend_module.py.
// DO NOT EDIT.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34
// Source SHA256: 921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda
package platformsupport
@ -13,7 +13,7 @@ const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MA
// ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce
// this projection.
const ManifestSourceSHA256 = "01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34"
const ManifestSourceSHA256 = "921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda"
// ManifestSchemaVersion is the schema_version field of the manifest.
const ManifestSchemaVersion = 2

View file

@ -513,6 +513,51 @@ func (c *Client) GetAlerts(ctx context.Context) ([]Alert, error) {
return alerts, nil
}
// GetVMs returns the best-effort native TrueNAS VM inventory. TrueNAS 25.04+
// documents vm.query on the JSON-RPC API, with the legacy REST endpoint kept
// as a compatibility fallback for existing client tests and older deployments.
func (c *Client) GetVMs(ctx context.Context) ([]VirtualMachine, error) {
vms, err := c.getVMsRPC(ctx)
if err == nil {
return vms, nil
}
restVMs, restErr := c.getVMsREST(ctx)
if restErr != nil {
return nil, fmt.Errorf("fetch truenas vms via rpc and rest: rpc=%w rest=%v", err, restErr)
}
return restVMs, nil
}
func (c *Client) getVMsRPC(ctx context.Context) ([]VirtualMachine, error) {
conn, err := c.dialRPC(ctx)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
rpc := trueNASRPCClient{
conn: conn,
nextID: 1,
}
if err := rpc.authenticate(ctx, c.config); err != nil {
return nil, err
}
var response []map[string]any
if err := rpc.call(ctx, "vm.query", []any{[]any{}, map[string]any{}}, &response); err != nil {
return nil, err
}
return parseVirtualMachines(response), nil
}
func (c *Client) getVMsREST(ctx context.Context) ([]VirtualMachine, error) {
var response []map[string]any
if err := c.getJSON(ctx, http.MethodGet, "/vm", &response); err != nil {
return nil, err
}
return parseVirtualMachines(response), nil
}
// GetApps returns the best-effort TrueNAS app inventory as canonical workload
// candidates. The API surface varies across TrueNAS releases, so we parse the
// documented app.query response shape loosely.
@ -841,6 +886,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) {
// Recovery artifacts are best-effort: do not fail monitoring if additional endpoints are unavailable.
apps, _ := c.GetApps(ctx)
vms, _ := c.GetVMs(ctx)
zfsSnapshots, _ := c.GetZFSSnapshots(ctx)
replicationTasks, _ := c.GetReplicationTasks(ctx)
@ -852,6 +898,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) {
Disks: disks,
Alerts: alerts,
Apps: apps,
VMs: vms,
ZFSSnapshots: zfsSnapshots,
ReplicationTasks: replicationTasks,
}, nil
@ -2411,6 +2458,132 @@ func readFloatAny(record map[string]any, keys ...string) float64 {
return 0
}
func parseVirtualMachines(entries []map[string]any) []VirtualMachine {
if len(entries) == 0 {
return nil
}
vms := make([]VirtualMachine, 0, len(entries))
for _, item := range entries {
if item == nil {
continue
}
status := readMapAny(item, "status")
deviceCounts := parseVMDeviceCounts(readSliceAny(item, "devices"))
memory := trueNASVMMemoryBytes(readInt64Any(item, "memory"))
minMemory := trueNASVMMemoryBytes(readInt64Any(item, "min_memory", "minMemory"))
id := strings.TrimSpace(readStringAny(item, "id"))
name := strings.TrimSpace(readStringAny(item, "name"))
if id == "" {
id = name
}
if name == "" {
name = id
}
vm := VirtualMachine{
ID: id,
Name: name,
Description: strings.TrimSpace(readStringAny(item, "description")),
State: strings.TrimSpace(readStringAny(status, "state")),
DomainState: strings.TrimSpace(readStringAny(status, "domain_state", "domainState")),
PID: readIntAny(status, "pid"),
VCPUs: readIntAny(item, "vcpus"),
Cores: readIntAny(item, "cores"),
Threads: readIntAny(item, "threads"),
MemoryBytes: memory,
MinMemoryBytes: minMemory,
CPUMode: strings.TrimSpace(readStringAny(item, "cpu_mode", "cpuMode")),
CPUModel: strings.TrimSpace(readStringAny(item, "cpu_model", "cpuModel")),
Bootloader: strings.TrimSpace(readStringAny(item, "bootloader")),
Autostart: readBoolAny(item, "autostart"),
SuspendOnSnapshot: readBoolAny(item, "suspend_on_snapshot", "suspendOnSnapshot"),
TrustedPlatformModule: readBoolAny(item, "trusted_platform_module", "trustedPlatformModule"),
SecureBoot: readBoolAny(item, "enable_secure_boot", "enableSecureBoot"),
Time: strings.TrimSpace(readStringAny(item, "time")),
ArchType: strings.TrimSpace(readStringAny(item, "arch_type", "archType")),
MachineType: strings.TrimSpace(readStringAny(item, "machine_type", "machineType")),
UUID: strings.TrimSpace(readStringAny(item, "uuid")),
DisplayAvailable: readBoolAny(item, "display_available", "displayAvailable"),
DeviceCount: deviceCounts.total,
DiskCount: deviceCounts.disks,
NICCount: deviceCounts.nics,
DisplayCount: deviceCounts.displays,
CDROMCount: deviceCounts.cdroms,
USBCount: deviceCounts.usbs,
PCICount: deviceCounts.pcis,
}
if vm.State == "" {
vm.State = strings.TrimSpace(readStringAny(item, "state"))
}
if vm.DomainState == "" {
vm.DomainState = strings.TrimSpace(readStringAny(item, "domain_state", "domainState"))
}
if vm.DeviceCount == 0 {
vm.DeviceCount = readIntAny(item, "device_count", "deviceCount")
}
if vm.ID == "" && vm.Name == "" {
continue
}
vms = append(vms, vm)
}
if len(vms) == 0 {
return nil
}
return vms
}
type vmDeviceCounts struct {
total int
disks int
nics int
displays int
cdroms int
usbs int
pcis int
}
func parseVMDeviceCounts(entries []any) vmDeviceCounts {
counts := vmDeviceCounts{total: len(entries)}
for _, entry := range entries {
record, ok := entry.(map[string]any)
if !ok {
continue
}
attributes := readMapAny(record, "attributes")
dtype := strings.ToUpper(strings.TrimSpace(readStringAny(attributes, "dtype")))
if dtype == "" {
dtype = strings.ToUpper(strings.TrimSpace(readStringAny(record, "dtype", "type")))
}
switch dtype {
case "DISK", "RAW":
counts.disks++
case "NIC":
counts.nics++
case "DISPLAY":
counts.displays++
case "CDROM":
counts.cdroms++
case "USB":
counts.usbs++
case "PCI":
counts.pcis++
}
}
return counts
}
func trueNASVMMemoryBytes(memory int64) int64 {
if memory <= 0 {
return 0
}
if memory >= 1<<30 {
return memory
}
return memory * 1024 * 1024
}
func parseAppPorts(entries []any) []AppPort {
if len(entries) == 0 {
return nil

View file

@ -248,9 +248,9 @@ func TestFetchSnapshot(t *testing.T) {
if snapshot.System.Hostname != "truenas-main" {
t.Fatalf("unexpected snapshot system: %+v", snapshot.System)
}
if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Apps) != 1 {
t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d",
len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps))
if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Apps) != 1 || len(snapshot.VMs) != 1 {
t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d vms=%d",
len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps), len(snapshot.VMs))
}
if snapshot.Disks[0].Temperature != 34 || snapshot.Disks[1].Temperature != 49 {
t.Fatalf("unexpected snapshot disk temperatures: %+v", snapshot.Disks)
@ -258,6 +258,85 @@ func TestFetchSnapshot(t *testing.T) {
if snapshot.Apps[0].ID != "nextcloud" || snapshot.Apps[0].ContainerCount != 2 {
t.Fatalf("unexpected snapshot apps: %+v", snapshot.Apps)
}
if snapshot.VMs[0].Name != "windows-lab" || snapshot.VMs[0].MemoryBytes != 8*1024*1024*1024 {
t.Fatalf("unexpected snapshot vms: %+v", snapshot.VMs)
}
}
func TestGetVMsParsesNativeVMQueryShape(t *testing.T) {
server := newMockServerWithRPC(t, map[string]apiResponse{}, nil, func(t *testing.T, conn *websocket.Conn) {
authReq := readRPCRequest(t, conn)
if authReq.Method != "auth.login_with_api_key" {
t.Fatalf("expected api-key auth method, got %q", authReq.Method)
}
writeRPCResult(t, conn, authReq.ID, true)
queryReq := readRPCRequest(t, conn)
if queryReq.Method != "vm.query" {
t.Fatalf("expected vm.query, got %q", queryReq.Method)
}
writeRPCResult(t, conn, queryReq.ID, []map[string]any{
{
"id": 42,
"name": "windows-lab",
"description": "Build test box",
"vcpus": 4,
"cores": 2,
"threads": 2,
"memory": 8192,
"min_memory": 4096,
"cpu_mode": "HOST-PASSTHROUGH",
"cpu_model": nil,
"bootloader": "UEFI",
"autostart": true,
"suspend_on_snapshot": true,
"trusted_platform_module": true,
"enable_secure_boot": true,
"time": "UTC",
"arch_type": "x86_64",
"machine_type": "q35",
"uuid": "vm-uuid-1",
"display_available": true,
"status": map[string]any{
"state": "RUNNING",
"pid": 1234,
"domain_state": "RUNNING",
},
"devices": []map[string]any{
{"id": 1, "attributes": map[string]any{"dtype": "DISK"}},
{"id": 2, "attributes": map[string]any{"dtype": "NIC"}},
{"id": 3, "attributes": map[string]any{"dtype": "DISPLAY"}},
{"id": 4, "attributes": map[string]any{"dtype": "CDROM"}},
},
},
})
})
t.Cleanup(server.Close)
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
vms, err := client.GetVMs(context.Background())
if err != nil {
t.Fatalf("GetVMs() error = %v", err)
}
if len(vms) != 1 {
t.Fatalf("expected 1 VM, got %d", len(vms))
}
vm := vms[0]
if vm.ID != "42" || vm.Name != "windows-lab" || vm.State != "RUNNING" || vm.DomainState != "RUNNING" {
t.Fatalf("unexpected VM identity/status: %+v", vm)
}
if vm.VCPUs != 4 || vm.Cores != 2 || vm.Threads != 2 {
t.Fatalf("unexpected VM CPU topology: %+v", vm)
}
if vm.MemoryBytes != 8*1024*1024*1024 || vm.MinMemoryBytes != 4*1024*1024*1024 {
t.Fatalf("unexpected VM memory: %+v", vm)
}
if !vm.Autostart || !vm.SuspendOnSnapshot || !vm.TrustedPlatformModule || !vm.SecureBoot {
t.Fatalf("expected VM boolean flags, got %+v", vm)
}
if vm.DeviceCount != 4 || vm.DiskCount != 1 || vm.NICCount != 1 || vm.DisplayCount != 1 || vm.CDROMCount != 1 {
t.Fatalf("unexpected VM device counts: %+v", vm)
}
}
func TestGetAppsEnrichesStatsFromRPC(t *testing.T) {
@ -1171,6 +1250,9 @@ func defaultAPIResponses() map[string]apiResponse {
"/api/v2.0/app": {
body: `[{"id":"nextcloud","name":"Nextcloud","state":"RUNNING","version":"1.0.3","human_version":"29.0.7","upgrade_available":true,"image_updates_available":true,"notes":"Team cloud","active_workloads":{"containers":2,"used_host_ips":["0.0.0.0"],"used_ports":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"container_details":[{"id":"nextcloud-web-1","service_name":"nextcloud","image":"docker.io/library/nextcloud:29.0.7","state":"running","port_config":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"volume_mounts":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"}]},{"id":"nextcloud-redis-1","service_name":"redis","image":"docker.io/library/redis:7.2","state":"running","port_config":[],"volume_mounts":[{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}]}],"volumes":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"},{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}],"images":["docker.io/library/nextcloud:29.0.7","docker.io/library/redis:7.2"],"networks":[{"name":"ix-nextcloud_default","id":"net-1","labels":{"com.docker.compose.project":"nextcloud"}}]}}]`,
},
"/api/v2.0/vm": {
body: `[{"id":42,"name":"windows-lab","vcpus":4,"cores":2,"threads":2,"memory":8192,"bootloader":"UEFI","autostart":true,"status":{"state":"RUNNING","pid":1234,"domain_state":"RUNNING"},"devices":[{"id":1,"attributes":{"dtype":"DISK"}},{"id":2,"attributes":{"dtype":"NIC"}}]}]`,
},
}
}

View file

@ -52,15 +52,15 @@ func TestRegistryIngestRecordsTreatsTrueNASAsGenericDataSource(t *testing.T) {
registry.IngestRecords(unifiedresources.SourceTrueNAS, records)
resources := registry.List()
wantCount := 1 + len(fixtures.Pools) + len(fixtures.Datasets) + len(fixtures.Disks) + len(fixtures.Apps)
wantCount := 1 + len(fixtures.Pools) + len(fixtures.Datasets) + len(fixtures.Disks) + len(fixtures.Apps) + len(fixtures.VMs)
if len(resources) != wantCount {
t.Fatalf("expected %d resources, got %d", wantCount, len(resources))
}
system := requireResource(t, resources, unifiedresources.ResourceTypeAgent, fixtures.System.Hostname)
assertSourceTracking(t, *system, unifiedresources.SourceTrueNAS)
if system.ChildCount != len(fixtures.Pools)+len(fixtures.Apps) {
t.Fatalf("expected system child count %d, got %d", len(fixtures.Pools)+len(fixtures.Apps), system.ChildCount)
if system.ChildCount != len(fixtures.Pools)+len(fixtures.Apps)+len(fixtures.VMs) {
t.Fatalf("expected system child count %d, got %d", len(fixtures.Pools)+len(fixtures.Apps)+len(fixtures.VMs), system.ChildCount)
}
if system.TrueNAS == nil {
t.Fatal("expected TrueNAS metadata on system record")
@ -170,6 +170,30 @@ func TestRegistryIngestRecordsTreatsTrueNASAsGenericDataSource(t *testing.T) {
t.Fatalf("expected Nextcloud network rates, got in=%v out=%v", app.Docker.NetInRate, app.Docker.NetOutRate)
}
vm := requireResource(t, resources, unifiedresources.ResourceTypeVM, "windows-lab")
assertSourceTracking(t, *vm, unifiedresources.SourceTrueNAS)
if vm.ParentID == nil || *vm.ParentID != system.ID {
t.Fatalf("expected VM parent %q, got %+v", system.ID, vm.ParentID)
}
if vm.TrueNAS == nil || vm.TrueNAS.VM == nil {
t.Fatal("expected native TrueNAS VM metadata on VM resource")
}
if vm.TrueNAS.VM.ID != "42" || vm.TrueNAS.VM.State != "RUNNING" {
t.Fatalf("unexpected native TrueNAS VM identity/state: %+v", vm.TrueNAS.VM)
}
if vm.TrueNAS.VM.VCPUs != 4 || vm.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 {
t.Fatalf("unexpected native TrueNAS VM compute metadata: %+v", vm.TrueNAS.VM)
}
if !vm.TrueNAS.VM.Autostart || !vm.TrueNAS.VM.SecureBoot || !vm.TrueNAS.VM.TrustedPlatformModule {
t.Fatalf("unexpected native TrueNAS VM flags: %+v", vm.TrueNAS.VM)
}
if vm.TrueNAS.VM.DiskCount != 1 || vm.TrueNAS.VM.NICCount != 1 || vm.TrueNAS.VM.DisplayCount != 1 {
t.Fatalf("unexpected native TrueNAS VM device counts: %+v", vm.TrueNAS.VM)
}
if vm.Status != unifiedresources.StatusOnline {
t.Fatalf("expected windows-lab status online, got %q", vm.Status)
}
disk := requireResource(t, resources, unifiedresources.ResourceTypePhysicalDisk, "sda")
assertSourceTracking(t, *disk, unifiedresources.SourceTrueNAS)
if disk.ParentID == nil || *disk.ParentID != pool.ID {
@ -240,7 +264,7 @@ func TestTrueNASResourcesFlowThroughUnifiedTypesWithoutSpecialCasing(t *testing.
t.Fatalf("expected canonical render type, got truenas-specific type for %s", resource.ID)
}
switch resource.Type {
case unifiedresources.ResourceTypeAgent, unifiedresources.ResourceTypeStorage, unifiedresources.ResourceTypePhysicalDisk, unifiedresources.ResourceTypeAppContainer:
case unifiedresources.ResourceTypeAgent, unifiedresources.ResourceTypeStorage, unifiedresources.ResourceTypePhysicalDisk, unifiedresources.ResourceTypeAppContainer, unifiedresources.ResourceTypeVM:
default:
t.Fatalf("unexpected unified type for truenas fixture resource: %s (%s)", resource.Type, resource.ID)
}

View file

@ -715,6 +715,59 @@ func DefaultFixtures() FixtureSnapshot {
Images: []string{"docker.io/adguard/adguardhome:v0.107.64"},
},
},
VMs: []VirtualMachine{
{
ID: "42",
Name: "windows-lab",
Description: "Build validation workstation",
State: "RUNNING",
DomainState: "RUNNING",
PID: 1234,
VCPUs: 4,
Cores: 2,
Threads: 2,
MemoryBytes: truenasGiB(8),
MinMemoryBytes: truenasGiB(4),
CPUMode: "HOST-PASSTHROUGH",
Bootloader: "UEFI",
Autostart: true,
SuspendOnSnapshot: true,
TrustedPlatformModule: true,
SecureBoot: true,
Time: "UTC",
ArchType: "x86_64",
MachineType: "q35",
UUID: "truenas-vm-windows-lab-uuid",
DisplayAvailable: true,
DeviceCount: 4,
DiskCount: 1,
NICCount: 1,
DisplayCount: 1,
CDROMCount: 1,
},
{
ID: "43",
Name: "ubuntu-build",
Description: "Linux build worker",
State: "STOPPED",
DomainState: "SHUTOFF",
VCPUs: 2,
Cores: 2,
Threads: 1,
MemoryBytes: truenasGiB(4),
CPUMode: "HOST-MODEL",
Bootloader: "UEFI",
Time: "UTC",
ArchType: "x86_64",
MachineType: "q35",
UUID: "truenas-vm-ubuntu-build-uuid",
DisplayAvailable: true,
DeviceCount: 3,
DiskCount: 1,
NICCount: 1,
DisplayCount: 1,
},
},
ZFSSnapshots: []ZFSSnapshot{
{
ID: "zfs-snapshot-tank-apps-20260331-0600",

View file

@ -457,7 +457,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time)
systemRisk := unifiedresources.StorageRiskFromAssessment(systemAssessment)
_, protectionReduced, rebuildInProgress, protectionSummary, rebuildSummary := unifiedresources.StorageRiskSemantics(systemRisk)
systemIncidents, poolIncidents, diskIncidents := buildIncidentAssignments(snapshot)
records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.Disks))
records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.VMs)+len(snapshot.Disks))
totalCapacity, totalUsed := aggregatePoolUsage(snapshot.Pools)
systemAgent := agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary)
@ -638,6 +638,32 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time)
})
}
for _, vm := range snapshot.VMs {
records = append(records, unifiedresources.IngestRecord{
SourceID: virtualMachineSourceID(vm),
ParentSourceID: systemSourceID,
Resource: unifiedresources.Resource{
Type: unifiedresources.ResourceTypeVM,
Name: virtualMachineDisplayName(vm),
Status: statusFromVirtualMachine(vm),
LastSeen: collectedAt,
UpdatedAt: collectedAt,
TrueNAS: &unifiedresources.TrueNASData{
Hostname: strings.TrimSpace(snapshot.System.Hostname),
VM: trueNASVMDataFromVirtualMachine(vm),
},
Tags: virtualMachineTags(vm),
},
Identity: unifiedresources.ResourceIdentity{
MachineID: strings.TrimSpace(vm.UUID),
Hostnames: dedupeStrings([]string{
strings.TrimSpace(snapshot.System.Hostname),
virtualMachineDisplayName(vm),
}),
},
})
}
for _, disk := range snapshot.Disks {
assessment := assessDisk(disk)
incidents := diskIncidents[strings.TrimSpace(disk.Name)]
@ -1209,6 +1235,13 @@ func appSourceID(id string) string {
return "app:" + strings.TrimSpace(id)
}
func virtualMachineSourceID(vm VirtualMachine) string {
if id := strings.TrimSpace(vm.ID); id != "" {
return "vm:" + id
}
return "vm:" + virtualMachineDisplayName(vm)
}
func diskSourceID(name string) string {
return "disk:" + strings.TrimSpace(name)
}
@ -1227,6 +1260,13 @@ func appCanonicalID(app App) string {
return appDisplayName(app)
}
func virtualMachineDisplayName(vm VirtualMachine) string {
if name := strings.TrimSpace(vm.Name); name != "" {
return name
}
return strings.TrimSpace(vm.ID)
}
func findAppInSnapshot(snapshot *FixtureSnapshot, appID string) (*App, error) {
if snapshot == nil {
return nil, fmt.Errorf("truenas snapshot is unavailable")
@ -1470,6 +1510,41 @@ func trueNASAppNetworksFromAppNetworks(networks []AppNetwork) []unifiedresources
return out
}
func trueNASVMDataFromVirtualMachine(vm VirtualMachine) *unifiedresources.TrueNASVM {
return &unifiedresources.TrueNASVM{
ID: strings.TrimSpace(vm.ID),
Name: strings.TrimSpace(vm.Name),
Description: strings.TrimSpace(vm.Description),
State: strings.TrimSpace(vm.State),
DomainState: strings.TrimSpace(vm.DomainState),
PID: vm.PID,
VCPUs: vm.VCPUs,
Cores: vm.Cores,
Threads: vm.Threads,
MemoryBytes: vm.MemoryBytes,
MinMemoryBytes: vm.MinMemoryBytes,
CPUMode: strings.TrimSpace(vm.CPUMode),
CPUModel: strings.TrimSpace(vm.CPUModel),
Bootloader: strings.TrimSpace(vm.Bootloader),
Autostart: vm.Autostart,
SuspendOnSnapshot: vm.SuspendOnSnapshot,
TrustedPlatformModule: vm.TrustedPlatformModule,
SecureBoot: vm.SecureBoot,
Time: strings.TrimSpace(vm.Time),
ArchType: strings.TrimSpace(vm.ArchType),
MachineType: strings.TrimSpace(vm.MachineType),
UUID: strings.TrimSpace(vm.UUID),
DisplayAvailable: vm.DisplayAvailable,
DeviceCount: vm.DeviceCount,
DiskCount: vm.DiskCount,
NICCount: vm.NICCount,
DisplayCount: vm.DisplayCount,
CDROMCount: vm.CDROMCount,
USBCount: vm.USBCount,
PCICount: vm.PCICount,
}
}
func copyStringLabels(labels map[string]string) map[string]string {
if len(labels) == 0 {
return nil
@ -1511,6 +1586,43 @@ func statusFromApp(app App) unifiedresources.ResourceStatus {
}
}
func statusFromVirtualMachine(vm VirtualMachine) unifiedresources.ResourceStatus {
state := strings.ToUpper(strings.TrimSpace(vm.State))
if state == "" {
state = strings.ToUpper(strings.TrimSpace(vm.DomainState))
}
switch state {
case "RUNNING", "ACTIVE":
return unifiedresources.StatusOnline
case "STOPPED", "SHUTOFF", "SHUTDOWN", "POWEROFF":
return unifiedresources.StatusOffline
case "PAUSED", "SUSPENDED", "ERROR", "CRASHED", "PANICKED":
return unifiedresources.StatusWarning
default:
return unifiedresources.StatusUnknown
}
}
func virtualMachineTags(vm VirtualMachine) []string {
tags := []string{"truenas", "vm"}
if state := strings.ToLower(strings.TrimSpace(vm.State)); state != "" {
tags = append(tags, "state:"+state)
}
if bootloader := strings.ToLower(strings.TrimSpace(vm.Bootloader)); bootloader != "" {
tags = append(tags, "boot:"+bootloader)
}
if vm.Autostart {
tags = append(tags, "autostart")
}
if vm.TrustedPlatformModule {
tags = append(tags, "tpm")
}
if vm.SecureBoot {
tags = append(tags, "secure-boot")
}
return dedupeStrings(tags)
}
func truenasAppCapabilities() []unifiedresources.ResourceCapability {
return []unifiedresources.ResourceCapability{
{
@ -1797,6 +1909,7 @@ func copyFixtureSnapshot(snapshot *FixtureSnapshot) *FixtureSnapshot {
copied.Disks = append([]Disk(nil), snapshot.Disks...)
copied.Alerts = append([]Alert(nil), snapshot.Alerts...)
copied.Apps = cloneApps(snapshot.Apps)
copied.VMs = append([]VirtualMachine(nil), snapshot.VMs...)
copied.ZFSSnapshots = append([]ZFSSnapshot(nil), snapshot.ZFSSnapshots...)
copied.ReplicationTasks = append([]ReplicationTask(nil), snapshot.ReplicationTasks...)
return &copied

View file

@ -740,6 +740,64 @@ func TestRecordsIncludeTrueNASAppsAsCanonicalWorkloads(t *testing.T) {
}
}
func TestRecordsIncludeTrueNASVMsAsCanonicalWorkloads(t *testing.T) {
previous := IsFeatureEnabled()
SetFeatureEnabled(true)
t.Cleanup(func() {
SetFeatureEnabled(previous)
})
provider := NewProvider(DefaultFixtures())
records := provider.Records()
if len(records) == 0 {
t.Fatal("expected fixture records from provider")
}
var windows *unifiedresources.IngestRecord
var ubuntu *unifiedresources.IngestRecord
for i := range records {
record := &records[i]
if record.Resource.Type != unifiedresources.ResourceTypeVM {
continue
}
switch record.Resource.Name {
case "windows-lab":
windows = record
case "ubuntu-build":
ubuntu = record
}
}
if windows == nil || ubuntu == nil {
t.Fatalf("expected TrueNAS VM records for windows-lab and ubuntu-build")
}
if windows.ParentSourceID != "system:truenas-main" {
t.Fatalf("expected windows-lab parent system:truenas-main, got %q", windows.ParentSourceID)
}
if windows.Resource.TrueNAS == nil || windows.Resource.TrueNAS.VM == nil {
t.Fatal("expected windows-lab native TrueNAS VM metadata")
}
if windows.Resource.TrueNAS.VM.ID != "42" || windows.Resource.TrueNAS.VM.Name != "windows-lab" {
t.Fatalf("unexpected windows-lab native VM identity: %+v", windows.Resource.TrueNAS.VM)
}
if windows.Resource.TrueNAS.VM.VCPUs != 4 || windows.Resource.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 {
t.Fatalf("unexpected windows-lab native VM compute metadata: %+v", windows.Resource.TrueNAS.VM)
}
if !windows.Resource.TrueNAS.VM.Autostart || !windows.Resource.TrueNAS.VM.SecureBoot || !windows.Resource.TrueNAS.VM.TrustedPlatformModule {
t.Fatalf("expected windows-lab native VM flags, got %+v", windows.Resource.TrueNAS.VM)
}
if windows.Resource.Status != unifiedresources.StatusOnline {
t.Fatalf("expected windows-lab status online, got %q", windows.Resource.Status)
}
if ubuntu.Resource.Status != unifiedresources.StatusOffline {
t.Fatalf("expected ubuntu-build status offline, got %q", ubuntu.Resource.Status)
}
if ubuntu.Resource.TrueNAS == nil || ubuntu.Resource.TrueNAS.VM == nil || ubuntu.Resource.TrueNAS.VM.DomainState != "SHUTOFF" {
t.Fatalf("expected ubuntu-build native VM domain state, got %+v", ubuntu.Resource.TrueNAS)
}
}
func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) {
previous := IsFeatureEnabled()
SetFeatureEnabled(true)

View file

@ -11,6 +11,7 @@ type FixtureSnapshot struct {
Disks []Disk
Alerts []Alert
Apps []App
VMs []VirtualMachine
ZFSSnapshots []ZFSSnapshot
ReplicationTasks []ReplicationTask
}
@ -216,6 +217,41 @@ type AppNetwork struct {
Labels map[string]string
}
// VirtualMachine mirrors the subset of TrueNAS VM fields needed for unified
// workload mapping from vm.query.
type VirtualMachine struct {
ID string
Name string
Description string
State string
DomainState string
PID int
VCPUs int
Cores int
Threads int
MemoryBytes int64
MinMemoryBytes int64
CPUMode string
CPUModel string
Bootloader string
Autostart bool
SuspendOnSnapshot bool
TrustedPlatformModule bool
SecureBoot bool
Time string
ArchType string
MachineType string
UUID string
DisplayAvailable bool
DeviceCount int
DiskCount int
NICCount int
DisplayCount int
CDROMCount int
USBCount int
PCICount int
}
// ZFSSnapshot mirrors the subset of snapshot fields needed for recovery-point mapping.
type ZFSSnapshot struct {
ID string

View file

@ -569,3 +569,45 @@ func TestRefreshCanonicalIdentityPreservesTrueNASAppContainerIdentity(t *testing
}
}
}
func TestRefreshCanonicalIdentityPreservesTrueNASVMIdentity(t *testing.T) {
resource := Resource{
ID: "truenas-vm-windows-lab",
Type: ResourceTypeVM,
Name: "windows-lab",
TrueNAS: &TrueNASData{
Hostname: "truenas-a.local",
VM: &TrueNASVM{
ID: "42",
Name: "windows-lab",
UUID: "vm-uuid-1",
},
},
MetricsTarget: &MetricsTarget{
ResourceType: string(ResourceTypeVM),
ResourceID: "42",
},
}
if got := ContractResourceType(resource); got != ResourceTypeVM {
t.Fatalf("ContractResourceType = %q, want %q", got, ResourceTypeVM)
}
RefreshCanonicalIdentity(&resource)
if resource.Canonical == nil {
t.Fatalf("expected canonical identity")
}
if got := resource.Canonical.DisplayName; got != "windows-lab" {
t.Fatalf("displayName = %q, want windows-lab", got)
}
if got := resource.Canonical.Hostname; got != "truenas-a.local" {
t.Fatalf("hostname = %q, want truenas-a.local", got)
}
if got := resource.Canonical.PlatformID; got != "truenas-a.local" {
t.Fatalf("platformId = %q, want truenas-a.local", got)
}
if got := resource.Canonical.PrimaryID; got != "vm:42" {
t.Fatalf("primaryId = %q, want vm:42", got)
}
}

View file

@ -358,6 +358,7 @@ func cloneTrueNASData(in *TrueNASData) *TrueNASData {
out := *in
out.StorageRisk = cloneStorageRisk(in.StorageRisk)
out.App = cloneTrueNASApp(in.App)
out.VM = cloneTrueNASVM(in.VM)
return &out
}
@ -416,6 +417,14 @@ func cloneTrueNASAppNetworks(in []TrueNASAppNetwork) []TrueNASAppNetwork {
return out
}
func cloneTrueNASVM(in *TrueNASVM) *TrueNASVM {
if in == nil {
return nil
}
out := *in
return &out
}
func cloneCPUInfo(in *CPUInfo) *CPUInfo {
if in == nil {
return nil

View file

@ -44,6 +44,11 @@ func BuildMetricsTarget(resource Resource, sourceTargets []SourceTarget) *Metric
if st, ok := bySource[SourceVMware]; ok {
return &MetricsTarget{ResourceType: "vm", ResourceID: st.SourceID}
}
if st, ok := bySource[SourceTrueNAS]; ok {
if resourceID := canonicalTrueNASVMMetricID(st.SourceID); resourceID != "" {
return &MetricsTarget{ResourceType: "vm", ResourceID: resourceID}
}
}
case ResourceTypeSystemContainer:
if st, ok := bySource[SourceProxmox]; ok {
return &MetricsTarget{ResourceType: "system-container", ResourceID: st.SourceID}
@ -149,6 +154,12 @@ func canonicalAppContainerMetricID(sourceID string) string {
return strings.TrimSpace(trimmed)
}
func canonicalTrueNASVMMetricID(sourceID string) string {
trimmed := strings.TrimSpace(sourceID)
trimmed = strings.TrimPrefix(trimmed, "vm:")
return strings.TrimSpace(trimmed)
}
func canonicalAgentMetricID(sourceID string) string {
trimmed := strings.TrimSpace(sourceID)
trimmed = strings.TrimPrefix(trimmed, "system:")

View file

@ -389,6 +389,27 @@ func TestBuildMetricsTarget_UsesCanonicalAgentMetricIDForTrueNAS(t *testing.T) {
}
}
func TestBuildMetricsTarget_UsesCanonicalVMMetricIDForTrueNAS(t *testing.T) {
target := BuildMetricsTarget(
Resource{
Type: ResourceTypeVM,
},
[]SourceTarget{{
Source: SourceTrueNAS,
SourceID: " vm:42 ",
}},
)
if target == nil {
t.Fatal("BuildMetricsTarget() returned nil")
}
if target.ResourceType != "vm" {
t.Fatalf("ResourceType = %q, want vm", target.ResourceType)
}
if target.ResourceID != "42" {
t.Fatalf("ResourceID = %q, want 42", target.ResourceID)
}
}
func TestMetricsFromKubernetesDeployment_NilOutsideMockMode(t *testing.T) {
mockruntime.SetEnabled(false)
got := metricsFromKubernetesDeployment(

View file

@ -1559,6 +1559,9 @@ func mergeTrueNASData(existing *TrueNASData, incoming *TrueNASData) *TrueNASData
if incoming.App != nil {
merged.App = cloneTrueNASApp(incoming.App)
}
if incoming.VM != nil {
merged.VM = cloneTrueNASVM(incoming.VM)
}
return &merged
}

View file

@ -165,6 +165,13 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
},
},
},
VM: &TrueNASVM{
ID: "42",
Name: "windows-lab",
State: "RUNNING",
VCPUs: 4,
MemoryBytes: 8 * 1024 * 1024 * 1024,
},
}
merged := mergeTrueNASData(existing, incoming)
@ -180,6 +187,12 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
if got := merged.App.ID; got != "nextcloud" {
t.Fatalf("app id = %q, want nextcloud", got)
}
if merged.VM == nil || merged.VM.ID != "42" || merged.VM.Name != "windows-lab" {
t.Fatalf("unexpected merged TrueNAS VM facet: %+v", merged.VM)
}
if merged.VM == incoming.VM {
t.Fatal("expected merged TrueNAS VM facet to be cloned")
}
incoming.App.Images[0] = "mutated:latest"
incoming.App.Volumes[0].Source = "mutated"

View file

@ -1040,6 +1040,7 @@ type TrueNASData struct {
RebuildInProgress bool `json:"rebuildInProgress,omitempty"`
RebuildSummary string `json:"rebuildSummary,omitempty"`
App *TrueNASApp `json:"app,omitempty"`
VM *TrueNASVM `json:"vm,omitempty"`
}
// TrueNASApp contains app.query / active_workloads data for one TrueNAS app.
@ -1108,6 +1109,40 @@ type TrueNASAppNetwork struct {
Labels map[string]string `json:"labels,omitempty"`
}
// TrueNASVM contains vm.query data for one TrueNAS virtual machine.
type TrueNASVM struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
State string `json:"state,omitempty"`
DomainState string `json:"domainState,omitempty"`
PID int `json:"pid,omitempty"`
VCPUs int `json:"vcpus,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
MemoryBytes int64 `json:"memoryBytes,omitempty"`
MinMemoryBytes int64 `json:"minMemoryBytes,omitempty"`
CPUMode string `json:"cpuMode,omitempty"`
CPUModel string `json:"cpuModel,omitempty"`
Bootloader string `json:"bootloader,omitempty"`
Autostart bool `json:"autostart,omitempty"`
SuspendOnSnapshot bool `json:"suspendOnSnapshot,omitempty"`
TrustedPlatformModule bool `json:"trustedPlatformModule,omitempty"`
SecureBoot bool `json:"secureBoot,omitempty"`
Time string `json:"time,omitempty"`
ArchType string `json:"archType,omitempty"`
MachineType string `json:"machineType,omitempty"`
UUID string `json:"uuid,omitempty"`
DisplayAvailable bool `json:"displayAvailable,omitempty"`
DeviceCount int `json:"deviceCount,omitempty"`
DiskCount int `json:"diskCount,omitempty"`
NICCount int `json:"nicCount,omitempty"`
DisplayCount int `json:"displayCount,omitempty"`
CDROMCount int `json:"cdromCount,omitempty"`
USBCount int `json:"usbCount,omitempty"`
PCICount int `json:"pciCount,omitempty"`
}
// AvailabilityData contains agentless endpoint probe metadata for a resource.
type AvailabilityData struct {
TargetID string `json:"targetId,omitempty"`