From 9ef4e588b7542e616830f502aad33381f90a33bb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 21 May 2026 12:08:25 +0100 Subject: [PATCH] Add TrueNAS services and stabilize dev startup --- .../subsystems/deployment-installability.md | 6 + .../v6/internal/subsystems/monitoring.md | 6 + .../internal/subsystems/storage-recovery.md | 13 +- .../internal/subsystems/unified-resources.md | 21 +- .../features/truenas/TrueNASPageSurface.tsx | 15 ++ .../features/truenas/TrueNASServicesTable.tsx | 221 ++++++++++++++++ .../features/truenas/TrueNASSystemsTable.tsx | 37 ++- .../__tests__/truenasPageModel.test.ts | 76 +++++- .../src/features/truenas/truenasPageModel.ts | 125 ++++++++- .../routing/__tests__/resourceLinks.test.ts | 1 + frontend-modern/src/types/resource.ts | 9 + internal/api/resources_test.go | 3 + internal/truenas/client.go | 79 ++++++ internal/truenas/client_test.go | 61 ++++- internal/truenas/fixtures.go | 28 ++ internal/truenas/provider.go | 36 +++ internal/truenas/provider_test.go | 42 +++ internal/truenas/types.go | 11 + .../canonical_ids_types_test.go | 32 +++ internal/unifiedresources/clone.go | 13 + internal/unifiedresources/registry.go | 3 + .../registry_merge_policy_test.go | 10 + internal/unifiedresources/store.go | 248 ++++++++++++++---- internal/unifiedresources/store_test.go | 58 ++++ internal/unifiedresources/types.go | 37 ++- scripts/hot-dev-bg.sh | 47 +++- scripts/hot-dev.sh | 18 +- scripts/tests/test-hot-dev-bg.sh | 44 ++++ .../tests/68-platform-pages-shell.spec.ts | 4 + 29 files changed, 1186 insertions(+), 118 deletions(-) create mode 100644 frontend-modern/src/features/truenas/TrueNASServicesTable.tsx diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1841864e9..6f29419e2 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 7b1b7e7bb..8727078b8 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 729e8d754..02eef2a8e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 54ed42034..51141607a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index 9c5aac0f0..906daefc5 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -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() { + + + @@ -137,6 +141,17 @@ function TrueNASApps(props: TrueNASOverviewProps) { ); } +function TrueNASServices(props: TrueNASOverviewProps) { + return ( + + ); +} + function TrueNASVMs(props: TrueNASOverviewProps) { return ( [] = [ + { 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 = { + 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, +): '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 ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + Service + + + State + + + Boot + + + + System + + + + + + {(row) => { + const status = () => mapTrueNASServiceStatus(row); + const pids = createMemo(() => formatPIDs(row.service.pids)); + const rawState = () => asTrimmedString(row.service.state) || '-'; + return ( + + +
+ +
+
+ {formatServiceName(row.service.service)} +
+
+ {asTrimmedString(row.service.id) || row.id} +
+
+
+
+ + + {titleCase(rawState())} + + + + + {row.service.enabled ? 'Enabled' : 'Disabled'} + + + + +
{row.systemName}
+
{row.systemId}
+
+
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default TrueNASServicesTable; diff --git a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx index 8ff8daa3d..49a766247 100644 --- a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx @@ -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<{ > - +
{/* 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. */} System - + CPU - + Memory - + Storage @@ -226,10 +227,15 @@ export const TrueNASSystemsTable: Component<{ Disks + @@ -367,12 +373,17 @@ export const TrueNASSystemsTable: Component<{ > {c().apps} + drawer.close(system)} /> diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index 11c17e1cf..c732ed7b7 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -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( diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index bcc48f4e2..221f7208b 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -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 { + 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 { 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 [ diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 862e0b603..4201ca4e4 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -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'); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 98363f28a..e25adf114 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -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 { diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index bc2c0517f..4048f55be 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -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) { diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 5074ea3f4..50e8e7183 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -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 diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 1747950b6..8c3a30d3f 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -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"}}]}}]`, }, diff --git a/internal/truenas/fixtures.go b/internal/truenas/fixtures.go index b5fcbbe96..ff5c01119 100644 --- a/internal/truenas/fixtures.go +++ b/internal/truenas/fixtures.go @@ -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", diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 7b086426d..833123037 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -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 diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index e368ee9bf..c81697481 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -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) diff --git a/internal/truenas/types.go b/internal/truenas/types.go index a48329c6a..27abd6815 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -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 { diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index e3a245938..5ea52c0c0 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -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 diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 0c3138cb7..15d73c6d8 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -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 diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 030007092..8bfafd975 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -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 } diff --git a/internal/unifiedresources/registry_merge_policy_test.go b/internal/unifiedresources/registry_merge_policy_test.go index c04931be5..a78c0c40f 100644 --- a/internal/unifiedresources/registry_merge_policy_test.go +++ b/internal/unifiedresources/registry_merge_policy_test.go @@ -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) { diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 9fe108728..eb140d72a 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -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, ¬Null, &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 diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index 8489cce00..e7762e709 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -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() diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 46295914f..fa061414c 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -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. diff --git a/scripts/hot-dev-bg.sh b/scripts/hot-dev-bg.sh index 67903597d..d611ef5e5 100755 --- a/scripts/hot-dev-bg.sh +++ b/scripts/hot-dev-bg.sh @@ -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() { diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 9a35b11b9..3485c6e8d 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -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..." diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh index 84c070b03..9b39ed3eb 100755 --- a/scripts/tests/test-hot-dev-bg.sh +++ b/scripts/tests/test-hot-dev-bg.sh @@ -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 diff --git a/tests/integration/tests/68-platform-pages-shell.spec.ts b/tests/integration/tests/68-platform-pages-shell.spec.ts index 2c634cdd8..6f7d822d4 100644 --- a/tests/integration/tests/68-platform-pages-shell.spec.ts +++ b/tests/integration/tests/68-platform-pages-shell.spec.ts @@ -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' },