mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Add TrueNAS services and stabilize dev startup
This commit is contained in:
parent
179f88d721
commit
9ef4e588b7
29 changed files with 1186 additions and 118 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
frontend-modern/src/features/truenas/TrueNASServicesTable.tsx
Normal file
221
frontend-modern/src/features/truenas/TrueNASServicesTable.tsx
Normal 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;
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}}]}}]`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue