Add TrueNAS services and stabilize dev startup

This commit is contained in:
rcourtman 2026-05-21 12:08:25 +01:00
parent 179f88d721
commit 9ef4e588b7
29 changed files with 1186 additions and 118 deletions

View file

@ -187,6 +187,12 @@ server-side update execution surfaces.
open the dev browser shell, while local browser/proof URLs normalize wildcard
binds back to `127.0.0.1` and advertise the detected LAN browser entrypoint
separately.
The hot-dev supervisor must also recover its managed PID file from a live
`hot-dev-bg.sh supervise` process before treating the runtime as unmanaged.
Backend health monitoring must distinguish HTTP startup grace from a missing
backend process: a missing `./pulse` process may be tolerated only for the
short configured missing-process grace, after which the managed runtime must
restart it instead of waiting for the full HTTP warmup window.
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
That release-promotion boundary also owns prerelease note packet lineage:
shipped RC notes must remain historically accurate, the top-level

View file

@ -136,6 +136,12 @@ truth for live infrastructure data.
same native-query rule: ZFS snapshots prefer `zfs.resource.snapshot.query`
with older `pool.snapshot.query`/REST compatibility fallback, and
replication tasks prefer `replication.query`.
TrueNAS system services are also native appliance inventory: `service.query`
is the preferred source for service name, boot enablement, runtime state,
and process IDs. Pulse must publish that data through `TrueNASData.Services`
on the owning top-level TrueNAS system resource instead of inventing a
generic service resource type or rendering services as Docker/container
rows.
## Forbidden Paths

View file

@ -145,13 +145,14 @@ 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 and VM
metadata, plus TrueNAS network-share metadata, as read-only workload and
storage-access context when explaining appliance-owned storage or protected
items. `TrueNASData.App`, `TrueNASData.VM`, and `TrueNASData.Share` remain
metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata,
as read-only workload, system, and storage-access context when explaining
appliance-owned storage or protected items. `TrueNASData.App`,
`TrueNASData.VM`, `TrueNASData.Share`, and `TrueNASData.Services` remain
unified-resource/platform truth sourced from the TrueNAS API; storage/recovery
must not reinterpret app containers, VM device inventory, share paths, volumes,
or update posture as storage ownership, restore entitlement, or a separate
Docker-only or TrueNAS-local inventory path.
must not reinterpret app containers, VM device inventory, service PIDs, share
paths, 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

@ -209,6 +209,11 @@ reintroduce false Swarm capability surfaces.
resources, redact filesystem paths through policy metadata, and derive
storage-parent relationships without being flattened into generic Docker or
storage rows.
TrueNAS service inventory remains a system-owned native facet rather than a
new generic resource type. `service.query` output must project into
`TrueNASData.Services` on the canonical top-level `agent` resource,
preserving service name, boot enablement, runtime state, and process IDs
through clone, merge, transport, and frontend decode paths.
2. Add typed accessors and views in `internal/unifiedresources/views.go`
3. Add source ingestion/adaptation in the adapter layer only
Frontend resource platform contracts in
@ -1225,9 +1230,11 @@ 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`, `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
`truenas-*` resource model just to add another capability.
shapes, with native system-owned service inventory carried as
`TrueNASData.Services` on the canonical `agent`. 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 `truenas-*` resource model just to add
another capability.
The canonical resource timeline now also owns durable incident-response facts
that materially changed resource investigation state. `ResourceChange` kinds
such as `alert_fired`, `alert_acknowledged`, `alert_unacknowledged`,
@ -1554,9 +1561,11 @@ The same change emitter now also classifies canonical incident changes as
anomalies stay attached to the canonical incident surface instead of being
inferred later from metric noise or alert-adjacent heuristics.
That store also now migrates legacy `resource_changes` tables that still carry
the pre-v6 `timestamp` column by backfilling canonical `observed_at` values,
adding the newer `occurred_at` field, and preserving the legacy timestamp on
write when the target database still requires it.
the pre-v6 `timestamp` column by adding canonical columns without rewriting
large historical tables during startup. Timeline reads must resolve legacy
`timestamp` and nullable `observed_at` values through read-time fallback
expressions, while writes preserve the legacy timestamp on target databases
that still require it.
`internal/api/resources.go` now exposes that same history through dedicated
`/api/resources/{id}/timeline` reads, while the bundled `/api/resources/{id}/facets`
surface keeps the facet summary and recent-change history available without

View file

@ -13,6 +13,7 @@ import { TrueNASAppsTable } from './TrueNASAppsTable';
import { TrueNASAlertsTable } from './TrueNASAlertsTable';
import { TrueNASNetworkSharesTable } from './TrueNASNetworkSharesTable';
import { TrueNASProtectionTable } from './TrueNASProtectionTable';
import { TrueNASServicesTable } from './TrueNASServicesTable';
import { TrueNASStorageTopologyTable } from './TrueNASStorageTopologyTable';
import { TrueNASSystemsTable } from './TrueNASSystemsTable';
import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable';
@ -90,6 +91,9 @@ export function TrueNASPageSurface() {
<Show when={activeTab() === 'storage'}>
<TrueNASStorage model={model} />
</Show>
<Show when={activeTab() === 'services'}>
<TrueNASServices model={model} />
</Show>
<Show when={activeTab() === 'apps'}>
<TrueNASApps model={model} />
</Show>
@ -137,6 +141,17 @@ function TrueNASApps(props: TrueNASOverviewProps) {
);
}
function TrueNASServices(props: TrueNASOverviewProps) {
return (
<TrueNASServicesTable
services={props.model().services}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS services"
emptyDescription="System services appear here once the TrueNAS API reports service.query inventory."
/>
);
}
function TrueNASVMs(props: TrueNASOverviewProps) {
return (
<TrueNASVirtualMachinesTable

View file

@ -0,0 +1,221 @@
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 { 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 {
filterTrueNASServices,
mapTrueNASServiceStatus,
type TrueNASServiceRow,
type TrueNASServiceStatusFilter,
} from './truenasPageModel';
const TRUENAS_SERVICE_STATUS_OPTIONS: PlatformTableFilterOption<TrueNASServiceStatusFilter>[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running', tone: 'success' },
{ value: 'attention', label: 'Attention', tone: 'warning' },
{ value: 'stopped', label: 'Stopped', tone: 'danger' },
{ value: 'disabled', label: 'Disabled' },
];
const SERVICE_NAME_LABELS: Record<string, string> = {
ftp: 'FTP',
nfs: 'NFS',
rsync: 'Rsync',
s3: 'S3',
smartd: 'SMART',
smb: 'SMB',
snmp: 'SNMP',
ssh: 'SSH',
ups: 'UPS',
};
const titleCase = (value: string | undefined): string => {
const normalized = asTrimmedString(value);
if (!normalized) return 'Unknown';
return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase();
};
const formatServiceName = (value: string | undefined): string => {
const normalized = asTrimmedString(value);
if (!normalized) return 'Unknown';
return SERVICE_NAME_LABELS[normalized.toLowerCase()] ?? normalized;
};
const formatPIDs = (pids: number[] | undefined): { label: string; title: string } => {
const values = (pids ?? []).filter((pid) => Number.isFinite(pid) && pid > 0);
if (values.length === 0) return { label: '-', title: '' };
const visible = values.slice(0, 3).map(String);
const suffix = values.length > visible.length ? ` +${values.length - visible.length}` : '';
return { label: `${visible.join(', ')}${suffix}`, title: values.join(', ') };
};
const serviceStatusVariant = (
status: Exclude<TrueNASServiceStatusFilter, 'all'>,
): 'success' | 'warning' | 'danger' | 'muted' => {
if (status === 'running') return 'success';
if (status === 'attention') return 'warning';
if (status === 'stopped') return 'danger';
return 'muted';
};
export const TrueNASServicesTable: Component<{
services: TrueNASServiceRow[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.services,
initialStatus: 'all' as TrueNASServiceStatusFilter,
filter: filterTrueNASServices,
});
return (
<Show
when={props.services.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 services"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_SERVICE_STATUS_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="services"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No services match current filters"
description="Adjust the search or status filter to see more TrueNAS services."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Services" />
<Table class="min-w-full table-fixed text-xs md:min-w-[900px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
Service
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
State
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[14%]`}>
Boot
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden sm:table-cell md:w-[20%]`}
>
PIDs
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[28%]`}>
System
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(row) => {
const status = () => mapTrueNASServiceStatus(row);
const pids = createMemo(() => formatPIDs(row.service.pids));
const rawState = () => asTrimmedString(row.service.state) || '-';
return (
<TableRow
class="text-[11px] hover:bg-surface-hover sm:text-xs"
data-truenas-service-row={row.id}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={serviceStatusVariant(status())}
title={titleCase(status())}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content">
{formatServiceName(row.service.service)}
</div>
<div class="truncate text-[11px] text-muted">
{asTrimmedString(row.service.id) || row.id}
</div>
</div>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class="inline-flex rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-base-content">
{titleCase(rawState())}
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span
class={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${
row.service.enabled
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: 'border-border bg-surface-alt text-muted'
}`}
>
{row.service.enabled ? 'Enabled' : 'Disabled'}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden sm:table-cell`}
title={pids().title || undefined}
>
<span class="tabular-nums text-base-content">{pids().label}</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="truncate text-base-content">{row.systemName}</div>
<div class="truncate text-[11px] text-muted">{row.systemId}</div>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default TrueNASServicesTable;

View file

@ -101,6 +101,7 @@ const EMPTY_COUNTS: TrueNASSystemChildCounts = {
vms: 0,
apps: 0,
disks: 0,
services: 0,
};
export const TrueNASSystemsTable: Component<{
@ -163,45 +164,45 @@ export const TrueNASSystemsTable: Component<{
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Systems'} />
<Table class="min-w-full table-fixed text-xs md:min-w-[1320px]">
<Table class="min-w-full table-fixed text-xs md:min-w-[1400px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
{/*
Desktop widths balance the bar-metric columns (CPU /
Memory / Storage) against the short integer-count columns
(Pools / Datasets / Shares / VMs / Disks / Apps) and give Version the
room it needs for full "TrueNAS-SCALE-24.10.2"-style
labels. Mobile widths are unchanged.
(Pools / Datasets / Shares / VMs / Disks / Apps /
Services) and give Version the room it needs for full
"TrueNAS-SCALE-24.10.2"-style labels. Mobile widths are unchanged.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[10%]`}>
System
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[13%]`}
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
>
Version
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[9%]`}>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[9%]`}>
Memory
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[11%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[10%]`}>
Storage
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[5%]`}
>
Temp
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[5%]`}
>
Uptime
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[5%]`}
>
Pools
</TableHead>
@ -226,10 +227,15 @@ export const TrueNASSystemsTable: Component<{
Disks
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[5%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
>
Apps
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[6%]`}
>
Services
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@ -367,12 +373,17 @@ export const TrueNASSystemsTable: Component<{
>
{c().apps}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{c().services}
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={system}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={13}
colSpan={14}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(system)}
/>

View file

@ -4,12 +4,14 @@ import type { Resource } from '@/types/resource';
import {
TRUENAS_TAB_SPECS,
buildTrueNASPageModel,
buildTrueNASServiceRows,
buildTrueNASStorageChildCounts,
buildTrueNASStorageTopologyRows,
buildTrueNASSystemChildCounts,
filterTrueNASApps,
filterTrueNASIncidents,
filterTrueNASProtectionPoints,
filterTrueNASServices,
filterTrueNASStorageTopologyRows,
filterTrueNASShares,
filterTrueNASVMs,
@ -17,6 +19,7 @@ import {
mapTrueNASIncidentSeverity,
mapTrueNASProtectionKind,
mapTrueNASProtectionStatus,
mapTrueNASServiceStatus,
mapTrueNASShareStatus,
mapTrueNASStorageStatus,
mapTrueNASVMStatus,
@ -49,6 +52,7 @@ describe('truenasPageModel', () => {
expect(TRUENAS_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'storage',
'services',
'apps',
'vms',
'shares',
@ -58,7 +62,11 @@ describe('truenasPageModel', () => {
it('buckets systems, workloads, and native storage inventory by TrueNAS API facet', () => {
const model = buildTrueNASPageModel([
makeResource({ id: 'truenas-system', type: 'agent' }),
makeResource({
id: 'truenas-system',
type: 'agent',
truenas: { services: [{ id: '1', service: 'smb', enabled: true, state: 'RUNNING' }] },
}),
makeResource({ id: 'truenas-vm', type: 'vm' }),
makeResource({ id: 'truenas-app', type: 'app-container' }),
makeResource({ id: 'truenas-share', type: 'network-share' }),
@ -81,6 +89,7 @@ describe('truenasPageModel', () => {
expect(model.shares.map((r) => r.id)).toEqual(['truenas-share']);
expect(model.vms.map((r) => r.id)).toEqual(['truenas-vm']);
expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']);
expect(model.services.map((row) => row.service.service)).toEqual(['smb']);
expect(model.pools.map((r) => r.id)).toEqual(['truenas-pool']);
expect(model.datasets.map((r) => r.id)).toEqual(['truenas-dataset']);
expect(model.disks.map((r) => r.id)).toEqual(['truenas-disk']);
@ -98,8 +107,23 @@ describe('truenasPageModel', () => {
});
it('counts overview inventory from each TrueNAS system hierarchy', () => {
const primary = makeResource({ id: 'system-primary', type: 'agent', name: 'nas-primary' });
const backup = makeResource({ id: 'system-backup', type: 'agent', name: 'nas-backup' });
const primary = makeResource({
id: 'system-primary',
type: 'agent',
name: 'nas-primary',
truenas: {
services: [
{ id: '1', service: 'smb', enabled: true, state: 'RUNNING' },
{ id: '2', service: 'ssh', enabled: false, state: 'STOPPED' },
],
},
});
const backup = makeResource({
id: 'system-backup',
type: 'agent',
name: 'nas-backup',
truenas: { services: [{ id: '1', service: 'nfs', enabled: true, state: 'RUNNING' }] },
});
const primaryPool = makeResource({
id: 'primary-pool-tank',
type: 'storage',
@ -156,6 +180,7 @@ describe('truenasPageModel', () => {
vms: 1,
apps: 1,
disks: 1,
services: 2,
});
expect(counts.get(backup.id)).toEqual({
pools: 1,
@ -164,11 +189,16 @@ describe('truenasPageModel', () => {
vms: 1,
apps: 1,
disks: 1,
services: 1,
});
});
it('keeps the single-system inventory fallback for older unparented TrueNAS resources', () => {
const system = makeResource({ id: 'system-primary', type: 'agent' });
const system = makeResource({
id: 'system-primary',
type: 'agent',
truenas: { services: [{ id: '1', service: 'smb', enabled: true, state: 'RUNNING' }] },
});
const counts = buildTrueNASSystemChildCounts(
[
system,
@ -189,9 +219,40 @@ describe('truenasPageModel', () => {
vms: 1,
apps: 1,
disks: 1,
services: 1,
});
});
it('builds and filters native TrueNAS service rows from system metadata', () => {
const system = makeResource({
id: 'system-primary',
type: 'agent',
name: 'nas-primary',
truenas: {
hostname: 'nas-primary',
services: [
{ id: '1', service: 'smb', enabled: true, state: 'RUNNING', pids: [2418, 2420] },
{ id: '2', service: 'ssh', enabled: false, state: 'STOPPED' },
{ id: '3', service: 'smartd', enabled: true, state: 'STOPPED' },
],
},
});
const rows = buildTrueNASServiceRows([system]);
expect(rows.map((row) => row.service.service)).toEqual(['smartd', 'ssh', 'smb']);
expect(mapTrueNASServiceStatus(rows[0])).toBe('stopped');
expect(mapTrueNASServiceStatus(rows[1])).toBe('disabled');
expect(mapTrueNASServiceStatus(rows[2])).toBe('running');
expect(filterTrueNASServices(rows, 'nas-primary', 'all')).toHaveLength(3);
expect(filterTrueNASServices(rows, 'smb', 'running').map((row) => row.service.service)).toEqual(
['smb'],
);
expect(filterTrueNASServices(rows, '', 'disabled').map((row) => row.service.service)).toEqual([
'ssh',
]);
});
it('builds a native TrueNAS storage topology with pool child counts', () => {
const system = makeResource({ id: 'system-primary', type: 'agent' });
const pool = makeResource({
@ -564,8 +625,11 @@ describe('truenasPageModel', () => {
expect(mapTrueNASProtectionStatus(legacyReplication)).toBe('warning');
expect(
filterTrueNASProtectionPoints([snapshot, replication, legacyReplication], 'vault', 'running')
.map((point) => point.id),
filterTrueNASProtectionPoints(
[snapshot, replication, legacyReplication],
'vault',
'running',
).map((point) => point.id),
).toEqual(['replicate-tank-apps']);
expect(
filterTrueNASProtectionPoints(

View file

@ -1,10 +1,23 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import { asTrimmedString } from '@/utils/stringUtils';
import type { Resource, ResourceIncident, ResourceType } from '@/types/resource';
import type {
Resource,
ResourceIncident,
ResourceTrueNASServiceMeta,
ResourceType,
} from '@/types/resource';
import type { RecoveryPoint } from '@/types/recovery';
export type TrueNASPageTabId = 'overview' | 'storage' | 'apps' | 'vms' | 'shares' | 'protection';
export type TrueNASPageTabId =
| 'overview'
| 'storage'
| 'services'
| 'apps'
| 'vms'
| 'shares'
| 'protection';
export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped';
export type TrueNASServiceStatusFilter = 'all' | 'running' | 'attention' | 'stopped' | 'disabled';
export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped';
export type TrueNASShareStatusFilter = 'all' | 'active' | 'attention' | 'disabled';
export type TrueNASIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info';
@ -26,12 +39,13 @@ export type TrueNASTabSpec = {
// The Overview tab is intentionally narrow: appliance systems and active
// health signals. Native API inventory has first-class homes: Storage for
// pools, datasets, and disks; Apps, VMs, and Shares for their TrueNAS API
// facets; and Protection for snapshots and replication over the canonical
// pools, datasets, and disks; Services, Apps, VMs, and Shares for their TrueNAS
// API facets; and Protection for snapshots and replication over the canonical
// recovery points contract.
export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
{ id: 'overview', label: 'Overview', path: '/truenas/overview' },
{ id: 'storage', label: 'Storage', path: '/truenas/storage' },
{ id: 'services', label: 'Services', path: '/truenas/services' },
{ id: 'apps', label: 'Apps', path: '/truenas/apps' },
{ id: 'vms', label: 'VMs', path: '/truenas/vms' },
{ id: 'shares', label: 'Shares', path: '/truenas/shares' },
@ -61,9 +75,18 @@ export type TrueNASPageModel = {
shares: Resource[];
vms: Resource[];
apps: Resource[];
services: TrueNASServiceRow[];
incidents: TrueNASIncidentRow[];
};
export type TrueNASServiceRow = {
id: string;
system: Resource;
systemId: string;
systemName: string;
service: ResourceTrueNASServiceMeta;
};
export type TrueNASIncidentRow = {
id: string;
resource: Resource;
@ -89,6 +112,7 @@ export type TrueNASSystemChildCounts = {
vms: number;
apps: number;
disks: number;
services: number;
};
export type TrueNASStorageChildCounts = {
@ -115,6 +139,7 @@ const emptyTrueNASSystemChildCounts = (): TrueNASSystemChildCounts => ({
vms: 0,
apps: 0,
disks: 0,
services: 0,
});
const emptyTrueNASStorageChildCounts = (): TrueNASStorageChildCounts => ({
@ -135,6 +160,7 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
const shares = trueNasResources.filter((resource) => resource.type === 'network-share');
const vms = trueNasResources.filter((resource) => resource.type === 'vm');
const apps = trueNasResources.filter((resource) => resource.type === 'app-container');
const services = buildTrueNASServiceRows(systems);
const incidents = buildTrueNASIncidentRows(trueNasResources);
return {
resources: trueNasResources,
@ -145,6 +171,7 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
shares,
vms,
apps,
services,
incidents,
};
}
@ -209,6 +236,12 @@ export function buildTrueNASSystemChildCounts(
}
}
for (const system of systems) {
const counts = countsBySystem.get(system.id);
if (!counts) continue;
counts.services = system.truenas?.services?.length ?? 0;
}
return countsBySystem;
}
@ -410,6 +443,27 @@ const compareStorageResources = (left: Resource, right: Resource): number => {
return resourceDisplayName(left).localeCompare(resourceDisplayName(right));
};
const serviceStatusRank = (row: TrueNASServiceRow): number => {
switch (mapTrueNASServiceStatus(row)) {
case 'attention':
return 0;
case 'stopped':
return 1;
case 'disabled':
return 2;
case 'running':
return 3;
}
};
const compareServiceRows = (left: TrueNASServiceRow, right: TrueNASServiceRow): number => {
const rankDelta = serviceStatusRank(left) - serviceStatusRank(right);
if (rankDelta !== 0) return rankDelta;
const systemDelta = left.systemName.localeCompare(right.systemName);
if (systemDelta !== 0) return systemDelta;
return serviceDisplayName(left.service).localeCompare(serviceDisplayName(right.service));
};
const incidentSeverityRank = (severity: string): number => {
switch (mapTrueNASIncidentSeverity(severity)) {
case 'critical':
@ -634,6 +688,27 @@ const resourceDisplayName = (resource: Resource): string =>
asTrimmedString(resource.truenas?.hostname) ||
resource.id;
const serviceDisplayName = (service: ResourceTrueNASServiceMeta): string =>
asTrimmedString(service.service) || asTrimmedString(service.id) || 'unknown';
export function buildTrueNASServiceRows(systems: Resource[]): TrueNASServiceRow[] {
const rows: TrueNASServiceRow[] = [];
for (const system of systems) {
const systemName = resourceDisplayName(system);
for (const service of system.truenas?.services ?? []) {
const serviceKey = serviceDisplayName(service);
rows.push({
id: `${system.id}:service:${asTrimmedString(service.id) || serviceKey}`,
system,
systemId: system.id,
systemName,
service,
});
}
}
return rows.sort(compareServiceRows);
}
const hasIncidentSignal = (incident: ResourceIncident): boolean =>
Boolean(asTrimmedString(incident.code) || asTrimmedString(incident.summary));
@ -737,6 +812,19 @@ export function mapTrueNASAppStatus(resource: Resource): Exclude<TrueNASAppStatu
return 'attention';
}
export function mapTrueNASServiceStatus(
row: TrueNASServiceRow,
): Exclude<TrueNASServiceStatusFilter, 'all'> {
const state = normalize(row.service.state);
if (['running', 'started', 'active'].includes(state)) return 'running';
if (['failed', 'error', 'crashed', 'degraded', 'unknown'].includes(state)) return 'attention';
if (['stopped', 'stop', 'inactive'].includes(state)) {
return row.service.enabled === false ? 'disabled' : 'stopped';
}
if (row.service.enabled === false) return 'disabled';
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';
@ -842,6 +930,35 @@ export function filterTrueNASApps(
});
}
const serviceSearchHaystack = (row: TrueNASServiceRow): string =>
[
row.id,
row.systemId,
row.systemName,
row.system.truenas?.hostname,
row.service.id,
row.service.service,
row.service.state,
row.service.enabled === true ? 'enabled boot start' : 'disabled no boot',
...(row.service.pids ?? []).map(String),
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
export function filterTrueNASServices(
services: TrueNASServiceRow[],
search: string,
status: TrueNASServiceStatusFilter,
): TrueNASServiceRow[] {
const needle = normalize(search);
return services.filter((service) => {
if (status !== 'all' && mapTrueNASServiceStatus(service) !== status) return false;
if (!needle) return true;
return serviceSearchHaystack(service).includes(needle);
});
}
const vmSearchHaystack = (resource: Resource): string => {
const vm = resource.truenas?.vm;
return [

View file

@ -55,6 +55,7 @@ describe('resource link routing contract', () => {
expect(TRUENAS_PATH).toBe('/truenas');
expect(buildTrueNASPath()).toBe('/truenas/overview');
expect(buildTrueNASPath('storage')).toBe('/truenas/storage');
expect(buildTrueNASPath('services')).toBe('/truenas/services');
expect(buildTrueNASPath('apps')).toBe('/truenas/apps');
expect(buildTrueNASPath('vms')).toBe('/truenas/vms');
expect(buildTrueNASPath('shares')).toBe('/truenas/shares');

View file

@ -721,6 +721,14 @@ export interface ResourceTrueNASShareMeta {
mapAllGroup?: string;
}
export interface ResourceTrueNASServiceMeta {
id?: string;
service?: string;
enabled?: boolean;
state?: string;
pids?: number[];
}
export interface ResourceTrueNASMeta {
hostname?: string;
version?: string;
@ -735,6 +743,7 @@ export interface ResourceTrueNASMeta {
app?: ResourceTrueNASAppMeta;
vm?: ResourceTrueNASVMMeta;
share?: ResourceTrueNASShareMeta;
services?: ResourceTrueNASServiceMeta[];
}
export interface ResourceKubernetesMetricCapabilities {

View file

@ -3529,6 +3529,9 @@ func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) {
if system.MetricsTarget == nil || system.MetricsTarget.ResourceType != "agent" || system.MetricsTarget.ResourceID != "truenas-main" {
t.Fatalf("expected canonical TrueNAS host metrics target truenas-main, got %+v", system.MetricsTarget)
}
if system.TrueNAS == nil || len(system.TrueNAS.Services) == 0 {
t.Fatalf("expected native TrueNAS services on system resource, got %+v", system.TrueNAS)
}
}
func TestResourceStorageIncidentsGroupsCanonicalSections(t *testing.T) {

View file

@ -723,6 +723,56 @@ func (c *Client) getAlertsREST(ctx context.Context) ([]Alert, error) {
return alerts, nil
}
// GetServices returns the native TrueNAS system service inventory.
func (c *Client) GetServices(ctx context.Context) ([]Service, error) {
services, err := c.getServicesRPC(ctx)
if err == nil {
return services, nil
}
restServices, restErr := c.getServicesREST(ctx)
if restErr != nil {
return nil, fmt.Errorf("fetch truenas services via rpc and rest: rpc=%w rest=%v", err, restErr)
}
return restServices, nil
}
func (c *Client) getServicesRPC(ctx context.Context) ([]Service, error) {
var response []map[string]any
if err := c.callRPC(ctx, "service.query", []any{[]any{}, map[string]any{
"extra": map[string]any{"include_state": true},
}}, &response); err != nil {
return nil, err
}
return parseServices(response), nil
}
func (c *Client) getServicesREST(ctx context.Context) ([]Service, error) {
var response []map[string]any
if err := c.getJSON(ctx, http.MethodGet, "/service", &response); err != nil {
return nil, err
}
return parseServices(response), nil
}
func parseServices(response []map[string]any) []Service {
services := make([]Service, 0, len(response))
for _, item := range response {
name := strings.TrimSpace(readStringAny(item, "service", "name"))
id := strings.TrimSpace(readStringAny(item, "id"))
if id == "" {
id = name
}
services = append(services, Service{
ID: id,
Service: name,
Enabled: readBoolAny(item, "enable", "enabled"),
State: strings.TrimSpace(readStringAny(item, "state", "status")),
PIDs: readIntSliceAny(item, "pids", "pid"),
})
}
return services
}
// 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.
@ -1253,6 +1303,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) {
}
// Recovery artifacts are best-effort: do not fail monitoring if additional endpoints are unavailable.
services, _ := c.GetServices(ctx)
apps, _ := c.GetApps(ctx)
vms, _ := c.GetVMs(ctx)
shares, _ := c.GetNetworkShares(ctx)
@ -1266,6 +1317,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) {
Datasets: datasets,
Disks: disks,
Alerts: alerts,
Services: services,
Apps: apps,
VMs: vms,
Shares: shares,
@ -2836,6 +2888,33 @@ func readIntAny(record map[string]any, keys ...string) int {
return 0
}
func readIntSliceAny(record map[string]any, keys ...string) []int {
if record == nil {
return nil
}
for _, key := range keys {
value, ok := record[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case []any:
out := make([]int, 0, len(typed))
for _, item := range typed {
if parsed, ok := parseInt64Any(item); ok {
out = append(out, int(parsed))
}
}
return out
default:
if parsed, ok := parseInt64Any(value); ok {
return []int{int(parsed)}
}
}
}
return nil
}
func readInt64Any(record map[string]any, keys ...string) int64 {
if record == nil {
return 0

View file

@ -262,9 +262,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 || len(snapshot.VMs) != 1 || len(snapshot.Shares) != 2 {
t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d vms=%d shares=%d",
len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps), len(snapshot.VMs), len(snapshot.Shares))
if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Services) != 2 || len(snapshot.Apps) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Shares) != 2 {
t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d services=%d apps=%d vms=%d shares=%d",
len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Services), len(snapshot.Apps), len(snapshot.VMs), len(snapshot.Shares))
}
if snapshot.Disks[0].Temperature != 34 || snapshot.Disks[1].Temperature != 49 {
t.Fatalf("unexpected snapshot disk temperatures: %+v", snapshot.Disks)
@ -275,6 +275,9 @@ func TestFetchSnapshot(t *testing.T) {
if snapshot.VMs[0].Name != "windows-lab" || snapshot.VMs[0].MemoryBytes != 8*1024*1024*1024 {
t.Fatalf("unexpected snapshot vms: %+v", snapshot.VMs)
}
if snapshot.Services[0].Service != "smb" || !snapshot.Services[0].Enabled || snapshot.Services[0].State != "RUNNING" {
t.Fatalf("unexpected snapshot services: %+v", snapshot.Services)
}
}
func TestGetPoolsDatasetsAndAlertsUseNativeQueryShapes(t *testing.T) {
@ -335,6 +338,55 @@ func TestGetPoolsDatasetsAndAlertsUseNativeQueryShapes(t *testing.T) {
}
}
func TestGetServicesUsesNativeServiceQueryShape(t *testing.T) {
server := newMockServerWithRPC(t, map[string]apiResponse{
"/api/v2.0/service": {status: http.StatusInternalServerError, body: `{"error":"legacy service endpoint should not be used"}`},
}, 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 != "service.query" {
t.Fatalf("expected service.query, got %q", queryReq.Method)
}
params, ok := queryReq.Params.([]any)
if !ok || len(params) != 2 {
t.Fatalf("expected service.query filters/options params, got %#v", queryReq.Params)
}
options, ok := params[1].(map[string]any)
if !ok {
t.Fatalf("expected service.query options, got %#v", params[1])
}
extra, ok := options["extra"].(map[string]any)
if !ok || extra["include_state"] != true {
t.Fatalf("expected service.query include_state option, got %#v", options)
}
writeRPCResult(t, conn, queryReq.ID, []map[string]any{
{"id": 1, "service": "smb", "enable": true, "state": "RUNNING", "pids": []int{2418, 2420}},
{"id": 2, "service": "ssh", "enable": false, "state": "STOPPED", "pids": []any{}},
})
})
t.Cleanup(server.Close)
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"})
services, err := client.GetServices(context.Background())
if err != nil {
t.Fatalf("GetServices() error = %v", err)
}
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
if services[0].ID != "1" || services[0].Service != "smb" || !services[0].Enabled || services[0].State != "RUNNING" || len(services[0].PIDs) != 2 {
t.Fatalf("unexpected smb service mapping: %+v", services[0])
}
if services[1].Service != "ssh" || services[1].Enabled || services[1].State != "STOPPED" {
t.Fatalf("unexpected ssh service mapping: %+v", services[1])
}
}
func TestGetDisksUsesNativeDiskQueryShape(t *testing.T) {
connectionCount := 0
server := newMockServerWithRPC(t, map[string]apiResponse{
@ -1580,6 +1632,9 @@ func defaultAPIResponses() map[string]apiResponse {
"/api/v2.0/alert/list": {
body: `[{"id":"a1","level":"WARNING","formatted":"Disk temp high","source":"DiskService","dismissed":false,"datetime":{"$date":1707400000000}}]`,
},
"/api/v2.0/service": {
body: `[{"id":1,"service":"smb","enable":true,"state":"RUNNING","pids":[2418,2420]},{"id":2,"service":"ssh","enable":false,"state":"STOPPED","pids":[]}]`,
},
"/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"}}]}}]`,
},

View file

@ -311,6 +311,34 @@ func DefaultFixtures() FixtureSnapshot {
Datetime: time.Date(2026, 3, 31, 8, 25, 0, 0, time.UTC),
},
},
Services: []Service{
{
ID: "1",
Service: "smb",
Enabled: true,
State: "RUNNING",
PIDs: []int{2418, 2420},
},
{
ID: "2",
Service: "nfs",
Enabled: true,
State: "RUNNING",
PIDs: []int{2501},
},
{
ID: "3",
Service: "ssh",
Enabled: false,
State: "STOPPED",
},
{
ID: "4",
Service: "smartd",
Enabled: true,
State: "STOPPED",
},
},
Apps: []App{
{
ID: "nextcloud",

View file

@ -484,6 +484,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time)
ProtectionSummary: protectionSummary,
RebuildInProgress: rebuildInProgress,
RebuildSummary: rebuildSummary,
Services: trueNASServicesFromServices(snapshot.Services),
},
Tags: []string{
"truenas",
@ -1483,6 +1484,28 @@ func primaryAppImage(app App) string {
return ""
}
func trueNASServicesFromServices(services []Service) []unifiedresources.TrueNASService {
if len(services) == 0 {
return nil
}
out := make([]unifiedresources.TrueNASService, 0, len(services))
for _, service := range services {
name := strings.TrimSpace(service.Service)
id := strings.TrimSpace(service.ID)
if id == "" {
id = name
}
out = append(out, unifiedresources.TrueNASService{
ID: id,
Service: name,
Enabled: service.Enabled,
State: strings.TrimSpace(service.State),
PIDs: append([]int(nil), service.PIDs...),
})
}
return out
}
func trueNASAppDataFromApp(app App) *unifiedresources.TrueNASApp {
containerCount := app.ContainerCount
if containerCount == 0 && len(app.Containers) > 0 {
@ -2079,6 +2102,7 @@ func copyFixtureSnapshot(snapshot *FixtureSnapshot) *FixtureSnapshot {
copied.Datasets = append([]Dataset(nil), snapshot.Datasets...)
copied.Disks = append([]Disk(nil), snapshot.Disks...)
copied.Alerts = append([]Alert(nil), snapshot.Alerts...)
copied.Services = cloneServices(snapshot.Services)
copied.Apps = cloneApps(snapshot.Apps)
copied.VMs = append([]VirtualMachine(nil), snapshot.VMs...)
copied.Shares = cloneNetworkShares(snapshot.Shares)
@ -2098,6 +2122,18 @@ func cloneSystemInfo(system SystemInfo) SystemInfo {
return cloned
}
func cloneServices(services []Service) []Service {
if len(services) == 0 {
return nil
}
out := make([]Service, len(services))
for i := range services {
out[i] = services[i]
out[i].PIDs = append([]int(nil), services[i].PIDs...)
}
return out
}
func cloneApps(apps []App) []App {
if len(apps) == 0 {
return nil

View file

@ -145,6 +145,7 @@ func TestFixtureFetcherReturnsSnapshotCopy(t *testing.T) {
first.Pools[0].Name = "mutated"
first.Datasets = append(first.Datasets, Dataset{Name: "extra/dataset"})
first.Services[0].PIDs[0] = 9999
first.System.TemperatureCelsius["cpu_package"] = 99.9
second, err := fetcher.Fetch(context.Background())
@ -160,6 +161,9 @@ func TestFixtureFetcherReturnsSnapshotCopy(t *testing.T) {
if len(second.Datasets) != len(fixtures.Datasets) {
t.Fatalf("expected dataset count %d, got %d", len(fixtures.Datasets), len(second.Datasets))
}
if second.Services[0].PIDs[0] != fixtures.Services[0].PIDs[0] {
t.Fatalf("expected fixture service PID %d, got %d", fixtures.Services[0].PIDs[0], second.Services[0].PIDs[0])
}
if second.System.TemperatureCelsius["cpu_package"] != fixtures.System.TemperatureCelsius["cpu_package"] {
t.Fatalf("expected fixture cpu_package temperature %v, got %v", fixtures.System.TemperatureCelsius["cpu_package"], second.System.TemperatureCelsius["cpu_package"])
}
@ -740,6 +744,44 @@ func TestRecordsIncludeTrueNASAppsAsCanonicalWorkloads(t *testing.T) {
}
}
func TestRecordsIncludeTrueNASServicesOnSystemResource(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 system *unifiedresources.IngestRecord
for i := range records {
if records[i].Resource.Type == unifiedresources.ResourceTypeAgent && records[i].SourceID == "system:truenas-main" {
system = &records[i]
break
}
}
if system == nil {
t.Fatal("expected TrueNAS system record")
}
if system.Resource.TrueNAS == nil {
t.Fatal("expected TrueNAS system metadata")
}
services := system.Resource.TrueNAS.Services
if len(services) != 4 {
t.Fatalf("expected 4 TrueNAS services, got %+v", services)
}
if services[0].Service != "smb" || !services[0].Enabled || services[0].State != "RUNNING" || len(services[0].PIDs) != 2 {
t.Fatalf("unexpected SMB service metadata: %+v", services[0])
}
if services[3].Service != "smartd" || !services[3].Enabled || services[3].State != "STOPPED" {
t.Fatalf("expected stopped enabled smartd service, got %+v", services[3])
}
}
func TestRecordsIncludeTrueNASVMsAsCanonicalWorkloads(t *testing.T) {
previous := IsFeatureEnabled()
SetFeatureEnabled(true)

View file

@ -10,6 +10,7 @@ type FixtureSnapshot struct {
Datasets []Dataset
Disks []Disk
Alerts []Alert
Services []Service
Apps []App
VMs []VirtualMachine
Shares []NetworkShare
@ -114,6 +115,16 @@ type Alert struct {
Datetime time.Time
}
// Service mirrors the service.query system service inventory returned by
// TrueNAS middleware.
type Service struct {
ID string
Service string
Enabled bool
State string
PIDs []int
}
// App mirrors the subset of TrueNAS application fields needed for unified
// workload mapping.
type App struct {

View file

@ -86,6 +86,38 @@ func TestContractResourceType(t *testing.T) {
}
}
func TestTrueNASServiceInventoryStaysOnSystemResource(t *testing.T) {
resource := Resource{
Type: ResourceTypeAgent,
TrueNAS: &TrueNASData{
Hostname: "truenas-a",
Services: []TrueNASService{
{
ID: "cifs",
Service: "smb",
Enabled: true,
State: "RUNNING",
PIDs: []int{1234, 5678},
},
},
},
}
if got := ContractResourceType(resource); got != ResourceTypeAgent {
t.Fatalf("ContractResourceType(TrueNAS system with services) = %q, want %q", got, ResourceTypeAgent)
}
if len(resource.TrueNAS.Services) != 1 {
t.Fatalf("TrueNAS service inventory length = %d, want 1", len(resource.TrueNAS.Services))
}
service := resource.TrueNAS.Services[0]
if service.ID != "cifs" || service.Service != "smb" || !service.Enabled || service.State != "RUNNING" {
t.Fatalf("unexpected TrueNAS service metadata: %+v", service)
}
if len(service.PIDs) != 2 || service.PIDs[0] != 1234 || service.PIDs[1] != 5678 {
t.Fatalf("unexpected TrueNAS service pid metadata: %+v", service.PIDs)
}
}
func TestCanonicalResourceIDDoesNotAliasLegacyHostPrefixes(t *testing.T) {
cases := []struct {
name string

View file

@ -360,9 +360,22 @@ func cloneTrueNASData(in *TrueNASData) *TrueNASData {
out.App = cloneTrueNASApp(in.App)
out.VM = cloneTrueNASVM(in.VM)
out.Share = cloneTrueNASShare(in.Share)
out.Services = cloneTrueNASServices(in.Services)
return &out
}
func cloneTrueNASServices(in []TrueNASService) []TrueNASService {
if len(in) == 0 {
return nil
}
out := make([]TrueNASService, len(in))
for i := range in {
out[i] = in[i]
out[i].PIDs = append([]int(nil), in[i].PIDs...)
}
return out
}
func cloneTrueNASApp(in *TrueNASApp) *TrueNASApp {
if in == nil {
return nil

View file

@ -1565,6 +1565,9 @@ func mergeTrueNASData(existing *TrueNASData, incoming *TrueNASData) *TrueNASData
if incoming.Share != nil {
merged.Share = cloneTrueNASShare(incoming.Share)
}
if len(incoming.Services) > 0 {
merged.Services = cloneTrueNASServices(incoming.Services)
}
return &merged
}

View file

@ -188,6 +188,9 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
Networks: []string{"10.10.20.0/24"},
Security: []string{"SYS"},
},
Services: []TrueNASService{
{ID: "1", Service: "smb", Enabled: true, State: "RUNNING", PIDs: []int{2418, 2420}},
},
}
merged := mergeTrueNASData(existing, incoming)
@ -215,6 +218,9 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
if merged.Share == incoming.Share {
t.Fatal("expected merged TrueNAS share facet to be cloned")
}
if len(merged.Services) != 1 || merged.Services[0].Service != "smb" || len(merged.Services[0].PIDs) != 2 {
t.Fatalf("unexpected merged TrueNAS services: %+v", merged.Services)
}
incoming.App.Images[0] = "mutated:latest"
incoming.App.Volumes[0].Source = "mutated"
@ -226,6 +232,7 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
incoming.Share.Hosts[0] = "mutated"
incoming.Share.Networks[0] = "mutated"
incoming.Share.Security[0] = "mutated"
incoming.Services[0].PIDs[0] = 9999
if got := merged.App.Images[0]; got != "nextcloud:stable" {
t.Fatalf("merged app image mutated through incoming slice: %q", got)
@ -257,6 +264,9 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) {
if got := merged.Share.Security[0]; got != "SYS" {
t.Fatalf("merged share security mutated through incoming slice: %q", got)
}
if got := merged.Services[0].PIDs[0]; got != 2418 {
t.Fatalf("merged service PIDs mutated through incoming slice: %d", got)
}
}
func TestTrueNASNetworkSharePolicyAndParentRelationship(t *testing.T) {

View file

@ -89,10 +89,12 @@ type ResourceExclusion struct {
// SQLiteResourceStore stores overrides in SQLite.
type SQLiteResourceStore struct {
db *sql.DB
dbPath string
resourceChangesHasTimestamp bool
mu sync.Mutex
db *sql.DB
dbPath string
resourceChangesHasTimestamp bool
resourceChangesHasSource bool
resourceChangesObservedAtNeedsFallback bool
mu sync.Mutex
}
const (
@ -529,9 +531,22 @@ func (s *SQLiteResourceStore) migrateResourceChangesSchema() error {
if err != nil {
return err
}
_, observedAtExisted := columns["observed_at"]
if _, ok := columns["timestamp"]; ok {
s.resourceChangesHasTimestamp = true
}
if _, ok := columns["source"]; ok {
s.resourceChangesHasSource = true
}
if !observedAtExisted {
s.resourceChangesObservedAtNeedsFallback = true
} else {
observedAtNotNull, err := s.tableColumnNotNull("resource_changes", "observed_at")
if err != nil {
return err
}
s.resourceChangesObservedAtNeedsFallback = !observedAtNotNull
}
if err := s.addResourceChangesColumnIfMissing(columns, "observed_at", "DATETIME"); err != nil {
return err
@ -618,51 +633,89 @@ func (s *SQLiteResourceStore) tableColumns(tableName string) (map[string]struct{
return columns, nil
}
func (s *SQLiteResourceStore) tableColumnNotNull(tableName, columnName string) (bool, error) {
rows, err := s.db.Query(`PRAGMA table_info(` + tableName + `)`)
if err != nil {
return false, fmt.Errorf("inspect %s schema: %w", tableName, err)
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
typ string
notNull int
dflt sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return false, fmt.Errorf("scan %s schema: %w", tableName, err)
}
if name == columnName {
return notNull != 0, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("iterate %s schema: %w", tableName, err)
}
return false, fmt.Errorf("column %s.%s not found", tableName, columnName)
}
func (s *SQLiteResourceStore) normalizeResourceChangeRows(columns map[string]struct{}) error {
assignments := []string{
"actor = COALESCE(NULLIF(TRIM(actor), ''), '')",
"related_resources = COALESCE(NULLIF(TRIM(related_resources), ''), '[]')",
"metadata_json = COALESCE(NULLIF(TRIM(metadata_json), ''), '{}')",
}
if _, ok := columns["source"]; ok {
assignments = append(assignments,
"source_adapter = CASE WHEN TRIM(COALESCE(source_adapter, '')) = '' THEN COALESCE(NULLIF(TRIM(source), ''), '') ELSE source_adapter END",
"source_type = CASE "+
"WHEN TRIM(COALESCE(source_type, '')) = '' THEN "+
"CASE WHEN lower(TRIM(COALESCE(source, ''))) IN ('platform_event', 'pulse_diff', 'heuristic', 'user_action', 'agent_action') "+
"THEN lower(TRIM(source)) ELSE 'pulse_diff' END "+
"ELSE lower(TRIM(source_type)) END",
)
} else {
assignments = append(assignments,
"source_adapter = COALESCE(NULLIF(TRIM(source_adapter), ''), '')",
"source_type = CASE WHEN TRIM(COALESCE(source_type, '')) = '' THEN 'pulse_diff' ELSE lower(TRIM(source_type)) END",
)
}
query := `UPDATE resource_changes SET ` + strings.Join(assignments, ", ")
if _, err := s.db.Exec(query); err != nil {
return fmt.Errorf("normalize resource_changes rows: %w", err)
}
// Legacy change tables can hold millions of historical rows. Keep schema
// migration startup-only and expose canonical defaults through read-time
// expressions instead of rewriting history while the API is still booting.
_ = columns
return nil
}
func (s *SQLiteResourceStore) backfillLegacyResourceChangeObservedAt(columns map[string]struct{}) error {
if _, ok := columns["observed_at"]; !ok {
return nil
}
expressions := []string{"observed_at = COALESCE(observed_at, CURRENT_TIMESTAMP)"}
if _, ok := columns["timestamp"]; ok {
expressions[0] = "observed_at = COALESCE(observed_at, timestamp, CURRENT_TIMESTAMP)"
}
query := `UPDATE resource_changes SET ` + expressions[0] + ` WHERE observed_at IS NULL OR TRIM(COALESCE(observed_at, '')) = ''`
if _, err := s.db.Exec(query); err != nil {
return fmt.Errorf("backfill resource_changes.observed_at: %w", err)
}
// See normalizeResourceChangeRows: legacy change history is exposed through
// read-time fallbacks so API startup stays bounded regardless of table size.
_ = columns
return nil
}
func (s *SQLiteResourceStore) resourceChangesObservedAtExpr() string {
if s.resourceChangesHasTimestamp {
return "COALESCE(observed_at, timestamp, CURRENT_TIMESTAMP)"
}
if s.resourceChangesObservedAtNeedsFallback {
return "COALESCE(observed_at, CURRENT_TIMESTAMP)"
}
return "observed_at"
}
func (s *SQLiteResourceStore) resourceChangesSourceTypeExpr() string {
sourceTypeExpr := "CASE WHEN TRIM(COALESCE(source_type, '')) = '' THEN 'pulse_diff' ELSE lower(TRIM(source_type)) END"
if !s.resourceChangesHasSource {
return sourceTypeExpr
}
legacySourceExpr := "lower(TRIM(COALESCE(source, '')))"
legacySourceTypeExpr := "CASE WHEN " + legacySourceExpr + " IN ('platform_event', 'pulse_diff', 'heuristic', 'user_action', 'agent_action') THEN " + legacySourceExpr + " ELSE 'pulse_diff' END"
return "CASE WHEN TRIM(COALESCE(source_type, '')) = '' OR (lower(TRIM(COALESCE(source_type, ''))) = 'pulse_diff' AND " + legacySourceExpr + " IN ('platform_event', 'pulse_diff', 'heuristic', 'user_action', 'agent_action')) THEN " + legacySourceTypeExpr + " ELSE lower(TRIM(source_type)) END"
}
func (s *SQLiteResourceStore) resourceChangesSourceAdapterExpr() string {
if s.resourceChangesHasSource {
return "CASE WHEN TRIM(COALESCE(source_adapter, '')) = '' THEN COALESCE(NULLIF(TRIM(source), ''), '') ELSE TRIM(source_adapter) END"
}
return "COALESCE(NULLIF(TRIM(source_adapter), ''), '')"
}
func resourceChangesActorExpr() string {
return "COALESCE(NULLIF(TRIM(actor), ''), '')"
}
func resourceChangesRelatedResourcesExpr() string {
return "COALESCE(NULLIF(TRIM(related_resources), ''), '[]')"
}
func resourceChangesMetadataJSONExpr() string {
return "COALESCE(NULLIF(TRIM(metadata_json), ''), '{}')"
}
func (s *SQLiteResourceStore) AddLink(link ResourceLink) error {
if link.ResourceA == "" || link.ResourceB == "" {
return fmt.Errorf("resource IDs required")
@ -857,8 +910,11 @@ func (s *SQLiteResourceStore) GetRecentChanges(canonicalID string, since time.Ti
}
func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since time.Time, limit int, filters ResourceChangeFilters) ([]ResourceChange, error) {
observedAtExpr := s.resourceChangesObservedAtExpr()
sourceTypeExpr := s.resourceChangesSourceTypeExpr()
sourceAdapterExpr := s.resourceChangesSourceAdapterExpr()
query := `
SELECT id, canonical_id, observed_at, occurred_at, COALESCE(kind, ''), COALESCE(from_state, ''), COALESCE(to_state, ''), COALESCE(source_type, ''), COALESCE(source_adapter, ''), COALESCE(actor, ''), COALESCE(confidence, ''), COALESCE(reason, ''), COALESCE(related_resources, ''), COALESCE(metadata_json, '')
SELECT id, canonical_id, ` + observedAtExpr + `, occurred_at, COALESCE(kind, ''), COALESCE(from_state, ''), COALESCE(to_state, ''), ` + sourceTypeExpr + `, ` + sourceAdapterExpr + `, ` + resourceChangesActorExpr() + `, COALESCE(confidence, ''), COALESCE(reason, ''), ` + resourceChangesRelatedResourcesExpr() + `, ` + resourceChangesMetadataJSONExpr() + `
FROM resource_changes`
args := []any{}
@ -867,11 +923,11 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
if canonicalID != "" {
conditions, args = appendRecentChangeResourceCondition(conditions, args, canonicalID, filters.IncludeRelated)
} else {
conditions = append(conditions, "observed_at >= ?")
conditions = append(conditions, observedAtExpr+" >= ?")
args = append(args, since)
}
if !since.IsZero() && canonicalID != "" {
conditions = append(conditions, "observed_at >= ?")
conditions = append(conditions, observedAtExpr+" >= ?")
args = append(args, since)
}
if len(filters.Kinds) > 0 {
@ -888,7 +944,7 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
placeholders = append(placeholders, "?")
args = append(args, string(sourceType))
}
conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")")
conditions = append(conditions, sourceTypeExpr+" IN ("+strings.Join(placeholders, ", ")+")")
}
if len(filters.SourceAdapters) > 0 {
placeholders := make([]string, 0, len(filters.SourceAdapters))
@ -896,13 +952,13 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
placeholders = append(placeholders, "?")
args = append(args, string(sourceAdapter))
}
conditions = append(conditions, "source_adapter IN ("+strings.Join(placeholders, ", ")+")")
conditions = append(conditions, sourceAdapterExpr+" IN ("+strings.Join(placeholders, ", ")+")")
}
if len(conditions) > 0 {
query += "\n\t\tWHERE " + strings.Join(conditions, " AND ")
}
query += `
ORDER BY observed_at DESC`
ORDER BY ` + observedAtExpr + ` DESC`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
@ -919,10 +975,16 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
var c ResourceChange
var conf, kindStr string
var relText, metaText sql.NullString
var observedAt any
if err := rows.Scan(&c.ID, &c.ResourceID, &c.ObservedAt, &c.OccurredAt, &kindStr, &c.From, &c.To, &c.SourceType, &c.SourceAdapter, &c.Actor, &conf, &c.Reason, &relText, &metaText); err != nil {
if err := rows.Scan(&c.ID, &c.ResourceID, &observedAt, &c.OccurredAt, &kindStr, &c.From, &c.To, &c.SourceType, &c.SourceAdapter, &c.Actor, &conf, &c.Reason, &relText, &metaText); err != nil {
return nil, fmt.Errorf("scan resource change row: %w", err)
}
parsedObservedAt, err := parseResourceChangeTime(observedAt)
if err != nil {
return nil, fmt.Errorf("parse resource change observed_at for %q: %w", c.ID, err)
}
c.ObservedAt = parsedObservedAt
c.ResourceID = CanonicalResourceID(c.ResourceID)
c.Kind = ChangeKind(kindStr)
@ -943,12 +1005,56 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
return changes, nil
}
func parseResourceChangeTime(value any) (time.Time, error) {
switch v := value.(type) {
case time.Time:
return v, nil
case string:
return parseResourceChangeTimeString(v)
case []byte:
return parseResourceChangeTimeString(string(v))
case nil:
return time.Time{}, nil
default:
return time.Time{}, fmt.Errorf("unsupported time value type %T", value)
}
}
func parseResourceChangeTimeString(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, nil
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05",
} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported time value %q", value)
}
func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time.Time) (int, error) {
return s.CountRecentChangesFiltered(canonicalID, since, ResourceChangeFilters{})
}
func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) {
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COUNT(*) FROM resource_changes")
query, args := buildRecentChangeCountQuery(
canonicalID,
since,
filters,
"SELECT COUNT(*) FROM resource_changes",
s.resourceChangesObservedAtExpr(),
s.resourceChangesSourceTypeExpr(),
s.resourceChangesSourceAdapterExpr(),
)
s.mu.Lock()
defer s.mu.Unlock()
@ -965,7 +1071,15 @@ func (s *SQLiteResourceStore) CountRecentChangesByKind(canonicalID string, since
}
func (s *SQLiteResourceStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) {
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes")
query, args := buildRecentChangeCountQuery(
canonicalID,
since,
filters,
"SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes",
s.resourceChangesObservedAtExpr(),
s.resourceChangesSourceTypeExpr(),
s.resourceChangesSourceAdapterExpr(),
)
query += ` GROUP BY kind`
s.mu.Lock()
@ -1000,8 +1114,17 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceType(canonicalID string,
}
func (s *SQLiteResourceStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) {
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(source_type, ''), COUNT(*) FROM resource_changes")
query += ` GROUP BY source_type`
sourceTypeExpr := s.resourceChangesSourceTypeExpr()
query, args := buildRecentChangeCountQuery(
canonicalID,
since,
filters,
"SELECT "+sourceTypeExpr+", COUNT(*) FROM resource_changes",
s.resourceChangesObservedAtExpr(),
sourceTypeExpr,
s.resourceChangesSourceAdapterExpr(),
)
query += ` GROUP BY ` + sourceTypeExpr
s.mu.Lock()
defer s.mu.Unlock()
@ -1035,8 +1158,17 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapter(canonicalID stri
}
func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) {
query, args := buildRecentChangeCountQuery(canonicalID, since, filters, "SELECT COALESCE(source_adapter, ''), COUNT(*) FROM resource_changes")
query += ` GROUP BY source_adapter`
sourceAdapterExpr := s.resourceChangesSourceAdapterExpr()
query, args := buildRecentChangeCountQuery(
canonicalID,
since,
filters,
"SELECT "+sourceAdapterExpr+", COUNT(*) FROM resource_changes",
s.resourceChangesObservedAtExpr(),
s.resourceChangesSourceTypeExpr(),
sourceAdapterExpr,
)
query += ` GROUP BY ` + sourceAdapterExpr
s.mu.Lock()
defer s.mu.Unlock()
@ -2071,10 +2203,10 @@ func (m *MemoryStore) CountRecentChangesBySourceAdapterFiltered(canonicalID stri
return counts, nil
}
func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters ResourceChangeFilters, selectClause string) (string, []any) {
func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters ResourceChangeFilters, selectClause string, observedAtExpr string, sourceTypeExpr string, sourceAdapterExpr string) (string, []any) {
query := selectClause
args := []any{}
conditions := []string{"observed_at >= ?"}
conditions := []string{observedAtExpr + " >= ?"}
args = append(args, since)
canonicalID = CanonicalResourceID(canonicalID)
if canonicalID != "" {
@ -2094,7 +2226,7 @@ func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters Re
placeholders = append(placeholders, "?")
args = append(args, string(sourceType))
}
conditions = append(conditions, "source_type IN ("+strings.Join(placeholders, ", ")+")")
conditions = append(conditions, sourceTypeExpr+" IN ("+strings.Join(placeholders, ", ")+")")
}
if len(filters.SourceAdapters) > 0 {
placeholders := make([]string, 0, len(filters.SourceAdapters))
@ -2102,7 +2234,7 @@ func buildRecentChangeCountQuery(canonicalID string, since time.Time, filters Re
placeholders = append(placeholders, "?")
args = append(args, string(sourceAdapter))
}
conditions = append(conditions, "source_adapter IN ("+strings.Join(placeholders, ", ")+")")
conditions = append(conditions, sourceAdapterExpr+" IN ("+strings.Join(placeholders, ", ")+")")
}
query += ` WHERE ` + strings.Join(conditions, " AND ")
return query, args

View file

@ -174,6 +174,14 @@ func TestNewSQLiteResourceStore_MigratesLegacyResourceChangesTable(t *testing.T)
}
defer store.Close()
var rawObservedAt sql.NullString
if err := store.db.QueryRow(`SELECT observed_at FROM resource_changes WHERE id = ?`, "chg-legacy").Scan(&rawObservedAt); err != nil {
t.Fatalf("query raw migrated observed_at: %v", err)
}
if rawObservedAt.Valid && strings.TrimSpace(rawObservedAt.String) != "" {
t.Fatalf("legacy observed_at was physically backfilled during startup: %q", rawObservedAt.String)
}
results, err := store.GetRecentChanges("vm:legacy", time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges on migrated legacy table returned error: %v", err)
@ -197,6 +205,14 @@ func TestNewSQLiteResourceStore_MigratesLegacyResourceChangesTable(t *testing.T)
t.Fatalf("legacy occurred_at = %v, want nil", results[0].OccurredAt)
}
count, err := store.CountRecentChanges("vm:legacy", time.Date(2026, 3, 18, 11, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CountRecentChanges on migrated legacy table returned error: %v", err)
}
if count != 1 {
t.Fatalf("CountRecentChanges on migrated legacy table returned %d, want 1", count)
}
if err := store.RecordChange(ResourceChange{
ID: "chg-new",
ResourceID: "vm:legacy",
@ -243,6 +259,48 @@ func TestNewSQLiteResourceStore_MigratesLegacyResourceChangesTable(t *testing.T)
}
}
func TestNormalizeResourceChangeRowsSkipsAlreadyCanonicalRows(t *testing.T) {
dataDir := t.TempDir()
store, err := NewSQLiteResourceStore(dataDir, defaultOrgID)
if err != nil {
t.Fatalf("NewSQLiteResourceStore returned error: %v", err)
}
defer store.Close()
if err := store.RecordChange(ResourceChange{
ID: "chg-canonical",
ResourceID: "vm:canonical",
ObservedAt: time.Date(2026, 5, 21, 10, 0, 0, 0, time.UTC),
Kind: ChangeRestart,
SourceType: SourcePlatformEvent,
SourceAdapter: AdapterProxmox,
Confidence: ConfidenceHigh,
Reason: "canonical row",
}); err != nil {
t.Fatalf("RecordChange failed: %v", err)
}
columns, err := resourceChangeColumns(store.db)
if err != nil {
t.Fatalf("resourceChangeColumns: %v", err)
}
var before int
if err := store.db.QueryRow(`SELECT total_changes()`).Scan(&before); err != nil {
t.Fatalf("SELECT total_changes() before normalize: %v", err)
}
if err := store.normalizeResourceChangeRows(columns); err != nil {
t.Fatalf("normalizeResourceChangeRows returned error: %v", err)
}
var after int
if err := store.db.QueryRow(`SELECT total_changes()`).Scan(&after); err != nil {
t.Fatalf("SELECT total_changes() after normalize: %v", err)
}
if after != before {
t.Fatalf("normalizeResourceChangeRows changed %d row(s), want 0", after-before)
}
}
func TestNewSQLiteResourceStore_InitializesCanonicalResourceChangesSchema(t *testing.T) {
dataDir := t.TempDir()

View file

@ -1030,19 +1030,30 @@ type VMwareData struct {
// TrueNASData contains TrueNAS-specific metadata for TrueNAS resources.
type TrueNASData struct {
Hostname string `json:"hostname,omitempty"`
Version string `json:"version,omitempty"`
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
StorageRisk *StorageRisk `json:"storageRisk,omitempty"`
StorageRiskSummary string `json:"storageRiskSummary,omitempty"`
StoragePostureSummary string `json:"storagePostureSummary,omitempty"`
ProtectionReduced bool `json:"protectionReduced,omitempty"`
ProtectionSummary string `json:"protectionSummary,omitempty"`
RebuildInProgress bool `json:"rebuildInProgress,omitempty"`
RebuildSummary string `json:"rebuildSummary,omitempty"`
App *TrueNASApp `json:"app,omitempty"`
VM *TrueNASVM `json:"vm,omitempty"`
Share *TrueNASShare `json:"share,omitempty"`
Hostname string `json:"hostname,omitempty"`
Version string `json:"version,omitempty"`
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
StorageRisk *StorageRisk `json:"storageRisk,omitempty"`
StorageRiskSummary string `json:"storageRiskSummary,omitempty"`
StoragePostureSummary string `json:"storagePostureSummary,omitempty"`
ProtectionReduced bool `json:"protectionReduced,omitempty"`
ProtectionSummary string `json:"protectionSummary,omitempty"`
RebuildInProgress bool `json:"rebuildInProgress,omitempty"`
RebuildSummary string `json:"rebuildSummary,omitempty"`
App *TrueNASApp `json:"app,omitempty"`
VM *TrueNASVM `json:"vm,omitempty"`
Share *TrueNASShare `json:"share,omitempty"`
Services []TrueNASService `json:"services,omitempty"`
}
// TrueNASService contains service.query data for one native TrueNAS system
// service.
type TrueNASService struct {
ID string `json:"id,omitempty"`
Service string `json:"service,omitempty"`
Enabled bool `json:"enabled"`
State string `json:"state,omitempty"`
PIDs []int `json:"pids,omitempty"`
}
// TrueNASApp contains app.query / active_workloads data for one TrueNAS app.

View file

@ -19,7 +19,8 @@ ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/lib/hot-dev-runtime.sh"
PID_FILE="${HOT_DEV_BG_PID_FILE:-${ROOT_DIR}/tmp/hot-dev.bg.pid}"
DEFAULT_HOT_DEV_BG_PID_FILE="${ROOT_DIR}/tmp/hot-dev.bg.pid"
PID_FILE="${HOT_DEV_BG_PID_FILE:-${DEFAULT_HOT_DEV_BG_PID_FILE}}"
LOG_FILE="${HOT_DEV_BG_LOG_FILE:-${ROOT_DIR}/tmp/hot-dev.bg.log}"
HOT_DEV_VERIFY_LOCK="${HOT_DEV_VERIFY_LOCK_FILE:-${ROOT_DIR}/tmp/hot-dev.verify.lock}"
@ -74,6 +75,36 @@ process_parent_id() {
ps -o ppid= -p "${pid}" 2>/dev/null | tr -d '[:space:]'
}
discover_managed_supervisor_pid() {
local pid command
while IFS= read -r pid; do
[[ -n "${pid}" ]] || continue
[[ "${pid}" != "$$" ]] || continue
command="$(process_command "${pid}")"
case "${command}" in
"bash ${ROOT_DIR}/scripts/hot-dev-bg.sh supervise"|"/bin/bash ${ROOT_DIR}/scripts/hot-dev-bg.sh supervise"|"${ROOT_DIR}/scripts/hot-dev-bg.sh supervise")
printf "%s\n" "${pid}"
return 0
;;
esac
done < <(pgrep -f "${ROOT_DIR}/scripts/hot-dev-bg.sh supervise" 2>/dev/null | sort -n)
return 1
}
recover_managed_pid_file() {
local discovered_pid
[[ "${PID_FILE}" == "${DEFAULT_HOT_DEV_BG_PID_FILE}" ]] || return 1
discovered_pid="$(discover_managed_supervisor_pid || true)"
[[ -n "${discovered_pid}" ]] || return 1
kill -0 "${discovered_pid}" 2>/dev/null || return 1
mkdir -p "$(dirname "${PID_FILE}")"
printf "%s\n" "${discovered_pid}" > "${PID_FILE}"
return 0
}
array_contains() {
local needle="${1:-}"
shift || true
@ -223,16 +254,22 @@ wait_for_managed_listener() {
is_running() {
if [[ ! -f "${PID_FILE}" ]]; then
return 1
recover_managed_pid_file
return $?
fi
local pid
pid="$(cat "${PID_FILE}" 2>/dev/null || true)"
[[ -n "${pid}" ]] || return 1
kill -0 "${pid}" 2>/dev/null
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
return 0
fi
rm -f "${PID_FILE}"
recover_managed_pid_file
}
managed_session_pid() {
cat "${PID_FILE}" 2>/dev/null || true
if is_running; then
cat "${PID_FILE}" 2>/dev/null || true
fi
}
describe_listener() {

View file

@ -488,16 +488,18 @@ log_info "Starting backend health monitor..."
UNHEALTHY_STREAK=0
UNHEALTHY_THRESHOLD="${HOT_DEV_BACKEND_UNHEALTHY_THRESHOLD:-2}"
BACKEND_HEALTH_STARTUP_GRACE_SECONDS="${HOT_DEV_BACKEND_HEALTH_STARTUP_GRACE_SECONDS:-180}"
BACKEND_PROCESS_MISSING_GRACE_SECONDS="${HOT_DEV_BACKEND_PROCESS_MISSING_GRACE_SECONDS:-10}"
[[ "${UNHEALTHY_THRESHOLD}" =~ ^[1-9][0-9]*$ ]] || UNHEALTHY_THRESHOLD=2
[[ "${BACKEND_HEALTH_STARTUP_GRACE_SECONDS}" =~ ^[0-9]+$ ]] || BACKEND_HEALTH_STARTUP_GRACE_SECONDS=180
[[ "${BACKEND_PROCESS_MISSING_GRACE_SECONDS}" =~ ^[0-9]+$ ]] || BACKEND_PROCESS_MISSING_GRACE_SECONDS=10
backend_serving() {
curl -sf -o /dev/null --max-time 3 \
"http://127.0.0.1:${PULSE_DEV_API_PORT:-7655}/api/health" 2>/dev/null
}
backend_in_startup_grace() {
backend_startup_age_seconds() {
local backend_started_at
local now
@ -506,7 +508,14 @@ log_info "Starting backend health monitor..."
[[ "${backend_started_at}" =~ ^[0-9]+$ ]] || return 1
now="$(date +%s)"
(( now - backend_started_at < BACKEND_HEALTH_STARTUP_GRACE_SECONDS ))
printf "%s\n" "$((now - backend_started_at))"
}
backend_in_startup_grace() {
local age
age="$(backend_startup_age_seconds)" || return 1
(( age < BACKEND_HEALTH_STARTUP_GRACE_SECONDS ))
}
while true; do
@ -514,9 +523,10 @@ log_info "Starting backend health monitor..."
PULSE_COUNT="$(hot_dev_pulse_process_count)"
if [[ "$PULSE_COUNT" -eq 0 ]]; then
if backend_in_startup_grace; then
startup_age="$(backend_startup_age_seconds || printf '%s\n' "${BACKEND_PROCESS_MISSING_GRACE_SECONDS}")"
if backend_in_startup_grace && (( startup_age < BACKEND_PROCESS_MISSING_GRACE_SECONDS )); then
UNHEALTHY_STREAK=0
log_warn "⚠️ Pulse process not running yet during backend startup grace (${BACKEND_HEALTH_STARTUP_GRACE_SECONDS}s)"
log_warn "⚠️ Pulse process not running yet during backend startup grace (${startup_age}/${BACKEND_PROCESS_MISSING_GRACE_SECONDS}s)"
continue
fi
log_warn "⚠️ Pulse died unexpectedly, restarting..."

View file

@ -357,6 +357,8 @@ test_launchd_session_supervises_managed_runtime() {
LOG_FILE="${test_dir}/hot-dev-bg.log"
require_python(){ :; }
start_bg(){ printf "%s\n" "999999" > "${PID_FILE}"; }
is_running(){ return 1; }
managed_session_pid(){ printf "%s\n" "999999"; }
launchd_session_bg true
status=$?
printf "status=%s\n" "${status}"
@ -744,9 +746,12 @@ test_hot_dev_health_monitor_probes_api_health() {
assert_contains "hot-dev health monitor declares a configurable unhealthy streak threshold" "${output}" 'UNHEALTHY_THRESHOLD="${HOT_DEV_BACKEND_UNHEALTHY_THRESHOLD:-2}"'
assert_contains "hot-dev health monitor declares backend startup grace" "${output}" 'BACKEND_HEALTH_STARTUP_GRACE_SECONDS="${HOT_DEV_BACKEND_HEALTH_STARTUP_GRACE_SECONDS:-180}"'
assert_contains "hot-dev health monitor declares missing-process grace" "${output}" 'BACKEND_PROCESS_MISSING_GRACE_SECONDS="${HOT_DEV_BACKEND_PROCESS_MISSING_GRACE_SECONDS:-10}"'
assert_contains "hot-dev health monitor tracks backend restart time through a shared marker" "${output}" 'BACKEND_STARTED_AT_FILE="${HOT_DEV_BACKEND_STARTED_AT_FILE:-${ROOT_DIR}/tmp/hot-dev.backend.started-at}"'
assert_contains "hot-dev managed restarts mark backend startup grace before killing" "${output}" 'mark_backend_startup_grace'
assert_contains "hot-dev health monitor measures backend startup age" "${output}" 'backend_startup_age_seconds()'
assert_contains "hot-dev health monitor checks startup grace before killing a live backend" "${output}" 'backend_in_startup_grace'
assert_contains "hot-dev health monitor bounds missing process grace separately" "${output}" 'startup_age < BACKEND_PROCESS_MISSING_GRACE_SECONDS'
assert_contains "hot-dev health monitor probes /api/health on the dev backend port" "${output}" '"http://127.0.0.1:${PULSE_DEV_API_PORT:-7655}/api/health"'
assert_contains "hot-dev health monitor restarts on alive-but-unresponsive state" "${output}" 'elif ! backend_serving; then'
assert_contains "hot-dev health monitor kills unresponsive Pulse before restart" "${output}" 'Killing unresponsive Pulse and restarting'
@ -790,6 +795,44 @@ test_hot_dev_bg_script_advertises_managed_entrypoint() {
assert_contains "hot-dev-bg routes verify guidance to managed wrapper" "${output}" "Rerun with: npm run dev:verify"
assert_contains "hot-dev-bg routes launchd supervision guidance to managed wrapper" "${output}" "Rerun with: npm run dev"
assert_contains "hot-dev-bg managed child skips npm wrapper cleanup" "${output}" "HOT_DEV_SKIP_NPM_CLEANUP=true"
assert_contains "hot-dev-bg can discover a live supervisor" "${output}" "discover_managed_supervisor_pid()"
assert_contains "hot-dev-bg can recover a missing managed pid file" "${output}" "recover_managed_pid_file()"
}
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor() {
local test_dir output
test_dir="$(mktemp -d)"
temp_dirs+=("${test_dir}")
output="$(
HOT_DEV_BG_PATH="${HOT_DEV_BG}" \
bash -lc '
source "${HOT_DEV_BG_PATH}"
DEFAULT_HOT_DEV_BG_PID_FILE="'"${test_dir}"'/hot-dev-bg.pid"
PID_FILE="${DEFAULT_HOT_DEV_BG_PID_FILE}"
printf "111\n" > "${PID_FILE}"
pgrep() { printf "4242\n"; }
process_command() { printf "bash %s/scripts/hot-dev-bg.sh supervise\n" "${ROOT_DIR}"; }
kill() {
if [[ "${1:-}" == "-0" && "${2:-}" == "4242" ]]; then
return 0
fi
return 1
}
if is_running; then
printf "running=yes\n"
else
printf "running=no\n"
fi
printf "pid_file=%s\n" "$(cat "${PID_FILE}")"
printf "managed_pid=%s\n" "$(managed_session_pid)"
'
)"
assert_contains "hot-dev-bg recovers running state from a live supervisor" "${output}" "running=yes"
assert_contains "hot-dev-bg rewrites stale pid file to discovered supervisor" "${output}" "pid_file=4242"
assert_contains "managed_session_pid returns recovered supervisor pid" "${output}" "managed_pid=4242"
}
test_hot_dev_bg_usage_prefers_managed_wrappers() {
@ -1292,6 +1335,7 @@ main() {
test_hot_dev_reconciles_agent_reachable_bind_address
test_hot_dev_health_monitor_probes_api_health
test_hot_dev_bg_script_advertises_managed_entrypoint
test_hot_dev_bg_recovers_stale_pid_file_from_live_supervisor
test_hot_dev_bg_usage_prefers_managed_wrappers
test_integration_readme_uses_managed_backend_restart_wrapper
test_integration_readme_documents_retired_trial_start_contract

View file

@ -78,6 +78,7 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
tabPaths: [
'/truenas/overview',
'/truenas/storage',
'/truenas/services',
'/truenas/apps',
'/truenas/vms',
'/truenas/shares',
@ -86,6 +87,7 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
populatedTabPaths: [
'/truenas/overview',
'/truenas/storage',
'/truenas/services',
'/truenas/apps',
'/truenas/vms',
'/truenas/shares',
@ -94,6 +96,7 @@ const PLATFORM_PAGES: readonly PlatformPageCase[] = [
populatedRowSelectors: {
'/truenas/overview': '[data-truenas-system-row]',
'/truenas/storage': '[data-truenas-storage-row]',
'/truenas/services': '[data-truenas-service-row]',
'/truenas/apps': '[data-truenas-app-row]',
'/truenas/vms': '[data-truenas-vm-row]',
'/truenas/shares': '[data-truenas-share-row]',
@ -209,6 +212,7 @@ test.describe('Platform pages shell', () => {
{ path: '/kubernetes/deployments', testId: 'kubernetes-page' },
{ path: '/truenas/overview', testId: 'truenas-page' },
{ path: '/truenas/storage', testId: 'truenas-page' },
{ path: '/truenas/services', testId: 'truenas-page' },
{ path: '/truenas/apps', testId: 'truenas-page' },
{ path: '/truenas/vms', testId: 'truenas-page' },
{ path: '/truenas/shares', testId: 'truenas-page' },