mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
2202f36674
commit
3fa46aa836
34 changed files with 1280 additions and 48 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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**:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@
|
|||
],
|
||||
"canonical_projections": [
|
||||
"agent",
|
||||
"vm",
|
||||
"app-container",
|
||||
"storage",
|
||||
"physical-disk"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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': [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}}]}]`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue