feat(truenas): surface poll health in platform connections

This commit is contained in:
rcourtman 2026-03-30 01:57:55 +01:00
parent 379ac30bff
commit ae2dabcfcd
18 changed files with 1225 additions and 72 deletions

View file

@ -448,6 +448,12 @@ composition boundary, and
the direct-node/discovery runtime hooks plus `useTrueNASSettingsPanelState.ts`
as the canonical provider state owners, instead of leaving those panels
ungoverned beside the canonical unified-agent install path.
That same lifecycle-owned platform-connections workspace must keep API-backed
provider state operationally useful, not CRUD-only. `TrueNASSettingsPanel.tsx`
and `useTrueNASSettingsPanelState.ts` must surface the shared runtime health,
poll cadence, discovered contribution summary, and canonical infrastructure /
workloads / storage / recovery handoffs coming from `/api/truenas/connections`
instead of falling back to panel-local inference or agent-first setup guidance.
That same API-backed platform workspace owner now also includes the shared
presentation helpers `frontend-modern/src/utils/clusterEndpointPresentation.ts`
and `frontend-modern/src/utils/proxmoxSettingsPresentation.ts`, so endpoint

View file

@ -614,6 +614,17 @@ must stay the canonical API-backed platform boundary for listing, creating,
updating, deleting, and testing TrueNAS integrations, and `PUT` updates must
preserve masked secrets (`********`) instead of clearing stored API keys or
passwords when operators edit non-secret fields from the settings surface.
That same `/api/truenas/connections` list boundary now also owns the
operator-facing runtime summary for those configured connections. The list
response must carry the canonical redacted config together with poll health
(`intervalSeconds`, last success/failure, consecutive failures) and discovered
platform contribution summary (host/resource identity plus systems, pools,
datasets, apps, disks, and recovery artifacts) so the platform-connections
workspace can render real API-backed status and handoff context without
inventing a settings-local shadow fetch path. Zero-value legacy
`pollIntervalSeconds` config must normalize back to the canonical 60-second
default at this same boundary instead of leaking ambiguous `0` values to the
frontend.
That same backend API boundary now also owns the negative space around
assistant control. Wiring native TrueNAS app actions into
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend

View file

@ -226,6 +226,13 @@ TrueNAS monitoring ownership now also includes provider rebind semantics in
host, auth, TLS, or fingerprint settings change, the poller must replace the
live provider instance instead of keeping stale connection state in memory
until the process restarts.
That same monitoring boundary now also owns canonical per-connection poll
health and discovered-summary state for the settings platform-connections
surface. `internal/monitoring/truenas_poller.go` must honor each configured
TrueNAS connection's `pollIntervalSeconds`, keep the next poll schedule plus
last success/failure state in one canonical runtime owner, and project the most
recent discovered host/pool/dataset/app/disk/recovery counts there instead of
recomputing settings health panel-by-panel.
That same monitoring boundary now also owns live TrueNAS disk temperatures.
`internal/truenas/client.go` and `internal/truenas/provider.go` must ingest
`disk.temperatures` from the TrueNAS API, fall back to modern

View file

@ -84,6 +84,7 @@ querying, and the operator-facing storage health presentation layer.
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.
25. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
## Forbidden Paths
@ -1534,6 +1535,12 @@ onboarding. Storage and recovery may consume resulting TrueNAS snapshots and
replication points, but connection CRUD, masked-secret preservation on update,
and platform polling setup must stay on the adjacent platform-connections
contract instead of being rebuilt as storage/recovery-local connection flows.
That same provider-backed boundary also owns connection poll cadence, last-sync
health, failure summaries, and discovered platform-contribution counts exposed
in the TrueNAS settings workspace. Storage and recovery may consume the
resulting datasets, apps, disks, and recovery artifacts, but they must not
redefine those settings-runtime health semantics or handoff routes inside
storage/recovery-local transport or page contracts.
That same shared boundary also owns the line between recovery data and
assistant control. Backend-native TrueNAS app actions may refresh the poller
and recovery ingest after a control event, but storage and recovery surfaces

View file

@ -22,6 +22,22 @@ describe('TrueNASAPI', () => {
useHttps: true,
insecureSkipVerify: false,
enabled: true,
pollIntervalSeconds: 120,
poll: {
intervalSeconds: 120,
lastSuccessAt: ' 2026-03-30T12:00:00Z ',
consecutiveFailures: 0,
},
observed: {
host: ' tower ',
resourceId: ' tower ',
systems: 1,
storagePools: 2,
datasets: 12,
apps: 4,
disks: 8,
recoveryArtifacts: 18,
},
},
]);
@ -38,7 +54,25 @@ describe('TrueNASAPI', () => {
insecureSkipVerify: false,
fingerprint: undefined,
enabled: true,
pollIntervalSeconds: undefined,
pollIntervalSeconds: 120,
poll: {
intervalSeconds: 120,
lastAttemptAt: undefined,
lastSuccessAt: '2026-03-30T12:00:00Z',
consecutiveFailures: 0,
lastError: undefined,
},
observed: {
host: 'tower',
resourceId: 'tower',
collectedAt: undefined,
systems: 1,
storagePools: 2,
datasets: 12,
apps: 4,
disks: 8,
recoveryArtifacts: 18,
},
},
]);
});

View file

@ -13,6 +13,36 @@ const REDACTED_SECRET = '********';
type RawTrueNASConnection = Partial<TrueNASConnection>;
type RawTrueNASConnectionPollError = Partial<TrueNASConnectionPollError>;
type RawTrueNASConnectionPoll = Partial<TrueNASConnectionPollStatus>;
type RawTrueNASConnectionObservedSummary = Partial<TrueNASConnectionObservedSummary>;
export interface TrueNASConnectionPollError {
at?: string;
message?: string;
category?: string;
}
export interface TrueNASConnectionPollStatus {
intervalSeconds?: number;
lastAttemptAt?: string;
lastSuccessAt?: string;
consecutiveFailures?: number;
lastError?: TrueNASConnectionPollError;
}
export interface TrueNASConnectionObservedSummary {
host?: string;
resourceId?: string;
collectedAt?: string;
systems: number;
storagePools: number;
datasets: number;
apps: number;
disks: number;
recoveryArtifacts: number;
}
export interface TrueNASConnection {
id: string;
name: string;
@ -26,6 +56,8 @@ export interface TrueNASConnection {
fingerprint?: string;
enabled: boolean;
pollIntervalSeconds?: number;
poll?: TrueNASConnectionPollStatus;
observed?: TrueNASConnectionObservedSummary;
}
export interface TrueNASConnectionInput {
@ -46,6 +78,47 @@ export interface TrueNASConnectionTestResult {
success: boolean;
}
const normalizeTrueNASConnectionPollError = (
error: RawTrueNASConnectionPollError | undefined,
): TrueNASConnectionPollError | undefined => {
if (!error || typeof error !== 'object') return undefined;
return {
at: optionalTrimmedString(error.at),
message: optionalTrimmedString(error.message),
category: optionalTrimmedString(error.category),
};
};
const normalizeTrueNASConnectionPoll = (
poll: RawTrueNASConnectionPoll | undefined,
): TrueNASConnectionPollStatus | undefined => {
if (!poll || typeof poll !== 'object') return undefined;
return {
intervalSeconds: finiteNumberOrUndefined(poll.intervalSeconds),
lastAttemptAt: optionalTrimmedString(poll.lastAttemptAt),
lastSuccessAt: optionalTrimmedString(poll.lastSuccessAt),
consecutiveFailures: finiteNumberOrUndefined(poll.consecutiveFailures),
lastError: normalizeTrueNASConnectionPollError(poll.lastError),
};
};
const normalizeTrueNASConnectionObservedSummary = (
observed: RawTrueNASConnectionObservedSummary | undefined,
): TrueNASConnectionObservedSummary | undefined => {
if (!observed || typeof observed !== 'object') return undefined;
return {
host: optionalTrimmedString(observed.host),
resourceId: optionalTrimmedString(observed.resourceId),
collectedAt: optionalTrimmedString(observed.collectedAt),
systems: finiteNumberOrUndefined(observed.systems) ?? 0,
storagePools: finiteNumberOrUndefined(observed.storagePools) ?? 0,
datasets: finiteNumberOrUndefined(observed.datasets) ?? 0,
apps: finiteNumberOrUndefined(observed.apps) ?? 0,
disks: finiteNumberOrUndefined(observed.disks) ?? 0,
recoveryArtifacts: finiteNumberOrUndefined(observed.recoveryArtifacts) ?? 0,
};
};
const normalizeTrueNASConnection = (connection: RawTrueNASConnection): TrueNASConnection => ({
id: trimmedString(connection.id),
name: optionalTrimmedString(connection.name) ?? '',
@ -59,6 +132,8 @@ const normalizeTrueNASConnection = (connection: RawTrueNASConnection): TrueNASCo
fingerprint: optionalTrimmedString(connection.fingerprint),
enabled: strictBoolean(connection.enabled),
pollIntervalSeconds: finiteNumberOrUndefined(connection.pollIntervalSeconds),
poll: normalizeTrueNASConnectionPoll(connection.poll),
observed: normalizeTrueNASConnectionObservedSummary(connection.observed),
});
const serializeTrueNASConnectionInput = (input: TrueNASConnectionInput) => ({

View file

@ -5,6 +5,14 @@ import ShieldAlert from 'lucide-solid/icons/shield-alert';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { Card } from '@/components/shared/Card';
import { Dialog } from '@/components/shared/Dialog';
import type { TrueNASConnection } from '@/api/truenas';
import {
buildInfrastructurePath,
buildRecoveryPath,
buildStoragePath,
buildWorkloadsPath,
} from '@/routing/resourceLinks';
import { formatNumber, formatRelativeTime } from '@/utils/format';
import {
formCheckbox,
formControl,
@ -23,6 +31,106 @@ const primaryButtonClass =
const dangerButtonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/30';
const connectionMetaBadgeClass =
'inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-muted';
const getPollIntervalLabel = (seconds: number | undefined): string => {
const value = seconds && Number.isFinite(seconds) && seconds > 0 ? seconds : 60;
if (value % 60 === 0) {
const minutes = value / 60;
return minutes === 1 ? 'Poll every 1 minute' : `Poll every ${minutes} minutes`;
}
return value === 1 ? 'Poll every 1 second' : `Poll every ${value} seconds`;
};
const getConnectionHealthPresentation = (connection: TrueNASConnection) => {
if (!connection.enabled) {
return {
label: 'Paused',
className: 'bg-surface text-muted',
detail: 'Polling paused',
error: null,
};
}
const poll = connection.poll;
const lastSuccessAt = poll?.lastSuccessAt;
const lastError = poll?.lastError;
const lastErrorAfterSuccess =
!!lastError?.at &&
(!lastSuccessAt || new Date(lastError.at).getTime() >= new Date(lastSuccessAt).getTime());
if (lastError && lastErrorAfterSuccess) {
return {
label: 'Sync failing',
className: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
detail: lastError.at
? `Last error ${formatRelativeTime(lastError.at, { compact: true })}`
: 'Last poll failed',
error: lastError.message || null,
};
}
if (lastSuccessAt) {
return {
label: 'Healthy',
className: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
detail: `Last sync ${formatRelativeTime(lastSuccessAt, { compact: true })}`,
error: null,
};
}
return {
label: 'Awaiting first sync',
className: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
detail: 'Pulse has not completed the first poll yet',
error: null,
};
};
const getConnectionObservedMetrics = (connection: TrueNASConnection) => {
const observed = connection.observed;
if (!observed) return [];
return [
{ label: 'system', value: observed.systems },
{ label: 'pool', value: observed.storagePools },
{ label: 'dataset', value: observed.datasets },
{ label: 'app', value: observed.apps },
{ label: 'disk', value: observed.disks },
{ label: 'recovery artifact', value: observed.recoveryArtifacts },
].filter((item) => item.value > 0);
};
const pluralize = (count: number, singular: string): string =>
count === 1 ? singular : `${singular}s`;
const getConnectionHandoffs = (connection: TrueNASConnection) => {
const host = (connection.observed?.host || '').trim();
const resourceId = (connection.observed?.resourceId || host).trim();
if (!host && !resourceId) {
return null;
}
return {
infrastructure: buildInfrastructurePath({
source: 'truenas',
...(resourceId ? { resource: resourceId } : {}),
}),
workloads: buildWorkloadsPath({
type: 'app-container',
platform: 'truenas',
...(host ? { agent: host } : {}),
}),
storage: buildStoragePath({
source: 'truenas',
...(resourceId ? { node: resourceId } : {}),
}),
recovery: buildRecoveryPath({
platform: 'truenas',
...(host ? { node: host } : {}),
}),
};
};
interface TrueNASSettingsPanelProps {
state: TrueNASSettingsPanelState;
}
@ -136,68 +244,159 @@ export const TrueNASSettingsPanel: Component<TrueNASSettingsPanelProps> = (props
<Show when={!state.loading() && !state.loadingError() && state.connections().length > 0}>
<div class="space-y-3">
<For each={state.connections()}>
{(connection) => (
<div class="rounded-lg border border-border bg-surface-alt px-4 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-base-content">
{connection.name || connection.host}
</h4>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
connection.enabled
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-surface text-muted'
}`}
>
{connection.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p class="text-sm text-muted">
{connection.host}
{connection.port ? `:${connection.port}` : ''}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>{connection.apiKey ? 'API key auth' : 'Username/password auth'}</span>
<span aria-hidden="true">•</span>
<span>{connection.useHttps ? 'HTTPS' : 'HTTP'}</span>
<Show when={connection.insecureSkipVerify}>
<>
{(connection) => {
const health = () => getConnectionHealthPresentation(connection);
const observedMetrics = () => getConnectionObservedMetrics(connection);
const handoffs = () => getConnectionHandoffs(connection);
return (
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-4"
data-testid={`truenas-connection-${connection.id}`}
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-base-content">
{connection.name || connection.host}
</h4>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
connection.enabled
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-surface text-muted'
}`}
>
{connection.enabled ? 'Enabled' : 'Disabled'}
</span>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${health().className}`}
>
{health().label}
</span>
</div>
<p class="text-sm text-muted">
{connection.host}
{connection.port ? `:${connection.port}` : ''}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
{connection.apiKey ? 'API key auth' : 'Username/password auth'}
</span>
<span aria-hidden="true">•</span>
<span>Skip TLS verification</span>
</>
<span>{connection.useHttps ? 'HTTPS' : 'HTTP'}</span>
<Show when={connection.insecureSkipVerify}>
<>
<span aria-hidden="true">•</span>
<span>Skip TLS verification</span>
</>
</Show>
<span aria-hidden="true">•</span>
<span>{getPollIntervalLabel(connection.pollIntervalSeconds)}</span>
<Show when={connection.poll?.lastAttemptAt || health().detail}>
<>
<span aria-hidden="true">•</span>
<span>{health().detail}</span>
</>
</Show>
</div>
<Show when={health().error}>
<p class="text-xs font-medium text-red-700 dark:text-red-300">
{health().error}
</p>
</Show>
</div>
<Show when={connection.observed}>
<div class="space-y-2">
<div class="flex flex-wrap gap-2">
<Show when={connection.observed?.collectedAt}>
<span class={connectionMetaBadgeClass}>
Synced{' '}
{formatRelativeTime(connection.observed?.collectedAt, {
compact: true,
})}
</span>
</Show>
<For each={observedMetrics()}>
{(item) => (
<span class={connectionMetaBadgeClass}>
{formatNumber(item.value)} {pluralize(item.value, item.label)}
</span>
)}
</For>
</div>
<Show when={handoffs()}>
{(links) => (
<div class="flex flex-wrap items-center gap-2">
<a
href={links().infrastructure}
class={buttonClass}
data-testid={`truenas-connection-${connection.id}-infrastructure`}
>
Infrastructure
</a>
<a
href={links().workloads}
class={buttonClass}
data-testid={`truenas-connection-${connection.id}-workloads`}
>
Workloads
</a>
<a
href={links().storage}
class={buttonClass}
data-testid={`truenas-connection-${connection.id}-storage`}
>
Storage
</a>
<a
href={links().recovery}
class={buttonClass}
data-testid={`truenas-connection-${connection.id}-recovery`}
>
Recovery
</a>
</div>
)}
</Show>
</div>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class={buttonClass}
onClick={() => void state.testSavedConnection(connection)}
disabled={state.testing()}
>
Test
</button>
<button
type="button"
class={buttonClass}
onClick={() => state.openEditDialog(connection)}
>
Edit
</button>
<button
type="button"
class={dangerButtonClass}
onClick={() => state.openDeleteDialog(connection)}
>
Delete
</button>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class={buttonClass}
onClick={() => void state.testSavedConnection(connection)}
disabled={state.testing()}
>
Test
</button>
<button
type="button"
class={buttonClass}
onClick={() => state.openEditDialog(connection)}
>
Edit
</button>
<button
type="button"
class={dangerButtonClass}
onClick={() => state.openDeleteDialog(connection)}
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
);
}}
</For>
</div>
</Show>
@ -250,6 +449,21 @@ export const TrueNASSettingsPanel: Component<TrueNASSettingsPanelProps> = (props
placeholder={state.form().useHttps ? '443' : '80'}
/>
</label>
<label class={formField}>
<span class={formLabel}>Poll interval (seconds)</span>
<input
class={formControl}
inputMode="numeric"
value={state.form().pollIntervalSeconds}
onInput={(event) =>
state.updateForm({ pollIntervalSeconds: event.currentTarget.value })
}
placeholder="60"
/>
<span class={formHelpText}>
How often Pulse should refresh this TrueNAS connection.
</span>
</label>
<label class={formField}>
<span class={formLabel}>Authentication</span>
<select

View file

@ -18,6 +18,7 @@ const mockState = vi.hoisted(() => ({
name: '',
host: '',
port: '',
pollIntervalSeconds: '60',
authMode: 'apiKey' as const,
apiKey: '',
username: '',
@ -62,6 +63,7 @@ describe('TrueNASSettingsPanel', () => {
name: '',
host: '',
port: '',
pollIntervalSeconds: '60',
authMode: 'apiKey',
apiKey: '',
username: '',
@ -94,9 +96,35 @@ describe('TrueNASSettingsPanel', () => {
host: 'truenas.local',
port: 443,
apiKey: '********',
pollIntervalSeconds: 60,
useHttps: true,
insecureSkipVerify: false,
enabled: true,
poll: {
intervalSeconds: 60,
lastSuccessAt: new Date(Date.now() - 60_000).toISOString(),
},
observed: {
host: 'tower',
resourceId: 'tower',
collectedAt: new Date(Date.now() - 60_000).toISOString(),
systems: 1,
storagePools: 2,
datasets: 12,
apps: 4,
disks: 8,
recoveryArtifacts: 18,
},
},
{
id: 'conn-2',
name: 'vault',
host: 'vault.local',
username: 'admin',
useHttps: true,
insecureSkipVerify: false,
enabled: false,
pollIntervalSeconds: 300,
},
]);
@ -106,6 +134,26 @@ describe('TrueNASSettingsPanel', () => {
expect(screen.getByText('TrueNAS connections')).toBeInTheDocument();
expect(screen.getByText('tower')).toBeInTheDocument();
expect(screen.getByText('API key auth')).toBeInTheDocument();
expect(screen.getByText('Healthy')).toBeInTheDocument();
expect(screen.getByText('Paused')).toBeInTheDocument();
expect(screen.getByText('Poll every 1 minute')).toBeInTheDocument();
expect(screen.getByText('Poll every 5 minutes')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Infrastructure' })).toHaveAttribute(
'href',
'/infrastructure?source=truenas&resource=tower',
);
expect(screen.getByRole('link', { name: 'Workloads' })).toHaveAttribute(
'href',
'/workloads?type=app-container&platform=truenas&agent=tower',
);
expect(screen.getByRole('link', { name: 'Storage' })).toHaveAttribute(
'href',
'/storage?source=truenas&node=tower',
);
expect(screen.getByRole('link', { name: 'Recovery' })).toHaveAttribute(
'href',
'/recovery?platform=truenas&node=tower',
);
expect(screen.getByRole('button', { name: 'Add TrueNAS connection' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add TrueNAS connection' }));
expect(mockState.openCreateDialog).toHaveBeenCalledTimes(1);

View file

@ -53,6 +53,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
enabled: true,
@ -63,6 +64,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
enabled: true,
@ -73,6 +75,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
enabled: true,
@ -85,6 +88,7 @@ describe('useTrueNASSettingsPanelState', () => {
result.openEditDialog(result.connections()[0]);
expect(result.dialogOpen()).toBe(true);
expect(result.form().pollIntervalSeconds).toBe('90');
await result.saveCurrentForm();
expect(TrueNASAPI.updateConnection).toHaveBeenCalledWith(
@ -92,6 +96,7 @@ describe('useTrueNASSettingsPanelState', () => {
expect.objectContaining({
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
username: '',
password: '',
}),

View file

@ -15,6 +15,7 @@ export interface TrueNASConnectionFormState {
name: string;
host: string;
port: string;
pollIntervalSeconds: string;
authMode: TrueNASAuthMode;
apiKey: string;
username: string;
@ -33,6 +34,7 @@ const createEmptyFormState = (): TrueNASConnectionFormState => ({
name: '',
host: '',
port: '',
pollIntervalSeconds: '60',
authMode: 'apiKey',
apiKey: '',
username: '',
@ -55,6 +57,7 @@ const buildFormStateFromConnection = (connection: TrueNASConnection): TrueNASCon
name: connection.name || '',
host: connection.host || '',
port: connection.port ? String(connection.port) : '',
pollIntervalSeconds: String(connection.pollIntervalSeconds ?? 60),
authMode,
apiKey: '',
username: authMode === 'userpass' ? connection.username || '' : '',
@ -78,6 +81,16 @@ const parseOptionalPort = (value: string): number | undefined => {
return parsed;
};
const parsePollIntervalSeconds = (value: string): number => {
const trimmed = value.trim();
if (!trimmed) return 60;
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 3600) {
throw new Error('Poll interval must be a whole number between 1 and 3600 seconds');
}
return parsed;
};
const getErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message.trim()) {
return error.message;
@ -98,6 +111,7 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnectionInput => {
const port = parseOptionalPort(form.port);
const pollIntervalSeconds = parsePollIntervalSeconds(form.pollIntervalSeconds);
const name = form.name.trim();
const host = form.host.trim();
const fingerprint = form.fingerprint.trim();
@ -106,6 +120,7 @@ const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnecti
...(name ? { name } : {}),
host,
...(port !== undefined ? { port } : {}),
pollIntervalSeconds,
useHttps: form.useHttps,
insecureSkipVerify: form.insecureSkipVerify,
...(fingerprint ? { fingerprint } : {}),

View file

@ -348,6 +348,7 @@ func (r *Router) setupRoutes() {
getPersistence: r.configHandlers.getPersistence,
getConfig: r.configHandlers.getConfig,
getMonitor: r.configHandlers.getMonitor,
getPoller: func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller },
}
recoveryManager := recoverymanager.New(r.multiTenant)
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)

View file

@ -22,9 +22,16 @@ type TrueNASHandlers struct {
getPersistence func(ctx context.Context) *config.ConfigPersistence
getConfig func(ctx context.Context) *config.Config
getMonitor func(ctx context.Context) *monitoring.Monitor
getPoller func(ctx context.Context) *monitoring.TrueNASPoller
newClient func(truenas.ClientConfig) (trueNASClient, error)
}
type trueNASConnectionResponse struct {
config.TrueNASInstance
Poll *monitoring.TrueNASConnectionPollStatus `json:"poll,omitempty"`
Observed *monitoring.TrueNASConnectionObservedSummary `json:"observed,omitempty"`
}
type trueNASClient interface {
TestConnection(ctx context.Context) error
Close()
@ -55,6 +62,7 @@ func (h *TrueNASHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) {
if instance.ID == "" {
instance.ID = config.NewTrueNASInstance().ID
}
instance.ApplyDefaults()
if err := instance.Validate(); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
@ -103,10 +111,26 @@ func (h *TrueNASHandlers) HandleList(w http.ResponseWriter, r *http.Request) {
return
}
redacted := make([]config.TrueNASInstance, 0, len(instances))
orgID := resolveTenantOrgID(r)
var summaries map[string]monitoring.TrueNASConnectionSummary
if h != nil && h.getPoller != nil {
if poller := h.getPoller(r.Context()); poller != nil {
summaries = poller.ConnectionSummaries(orgID, instances)
}
}
redacted := make([]trueNASConnectionResponse, 0, len(instances))
for i := range instances {
item := instances[i].Redacted()
redacted = append(redacted, item)
item := instances[i]
item.ApplyDefaults()
response := trueNASConnectionResponse{
TrueNASInstance: item.Redacted(),
}
if summary, ok := summaries[strings.TrimSpace(item.ID)]; ok {
response.Poll = summary.Poll
response.Observed = summary.Observed
}
redacted = append(redacted, response)
}
writeJSON(w, http.StatusOK, redacted)
@ -196,6 +220,7 @@ func (h *TrueNASHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) {
instance.Username = strings.TrimSpace(instance.Username)
instance.Password = strings.TrimSpace(instance.Password)
instance.Fingerprint = strings.TrimSpace(instance.Fingerprint)
instance.ApplyDefaults()
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {

View file

@ -7,7 +7,11 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
@ -214,6 +218,87 @@ func TestTrueNASHandlers_HandleList_RedactsSensitiveFields(t *testing.T) {
}
}
func TestTrueNASHandlers_HandleList_IncludesPollAndObservedSummary(t *testing.T) {
setTrueNASFeatureForTest(t, true)
cfg := &config.Config{DataPath: t.TempDir()}
handler, persistence, _ := newTrueNASHandlersForTest(t, cfg)
multiTenant := config.NewMultiTenantPersistence(cfg.DataPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v2.0/system/info":
_, _ = w.Write([]byte(`{"hostname":"tower","version":"TrueNAS-SCALE-24.10.2","buildtime":"24.10.2.1","uptime_seconds":86400,"system_serial":"SER-tower"}`))
case "/api/v2.0/pool":
_, _ = w.Write([]byte(`[{"id":1,"name":"tank","status":"ONLINE","size":1000,"allocated":400,"free":600}]`))
case "/api/v2.0/pool/dataset":
_, _ = w.Write([]byte(`[{"id":"tank/apps","name":"tank/apps","pool":"tank","used":{"rawvalue":"12345","parsed":12345},"available":{"rawvalue":"555","parsed":555},"mountpoint":"/mnt/tank/apps","readonly":{"rawvalue":"off","parsed":false},"mounted":true}]`))
case "/api/v2.0/disk":
_, _ = w.Write([]byte(`[{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"}]`))
case "/api/v2.0/alert/list":
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
connection := trueNASInstanceFromRawURL(t, "tower-1", server.URL, true)
connection.PollIntervalSecs = 60
if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil {
t.Fatalf("seed truenas config: %v", err)
}
poller := monitoring.NewTrueNASPoller(multiTenant, 50*time.Millisecond, nil)
poller.Start(context.Background())
t.Cleanup(poller.Stop)
handler.getPoller = func(context.Context) *monitoring.TrueNASPoller { return poller }
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
summaries := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})
if summary, ok := summaries[connection.ID]; ok && summary.Poll != nil && summary.Poll.LastSuccessAt != nil && summary.Observed != nil {
break
}
time.Sleep(25 * time.Millisecond)
}
req := httptest.NewRequest(http.MethodGet, "/api/truenas/connections", nil)
rec := httptest.NewRecorder()
handler.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var listed []trueNASConnectionResponse
if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected 1 listed instance, got %d", len(listed))
}
if listed[0].Poll == nil {
t.Fatalf("expected poll summary, got nil")
}
if listed[0].Poll.IntervalSeconds != 60 {
t.Fatalf("expected poll interval 60, got %d", listed[0].Poll.IntervalSeconds)
}
if listed[0].Poll.LastSuccessAt == nil {
t.Fatalf("expected last success timestamp in poll summary")
}
if listed[0].Observed == nil {
t.Fatalf("expected observed summary, got nil")
}
if listed[0].Observed.Host != "tower" || listed[0].Observed.ResourceID != "tower" {
t.Fatalf("unexpected observed host summary: %+v", listed[0].Observed)
}
if listed[0].Observed.StoragePools != 1 || listed[0].Observed.Datasets != 1 || listed[0].Observed.Disks != 1 {
t.Fatalf("unexpected observed counts: %+v", listed[0].Observed)
}
}
func TestTrueNASHandlers_HandleDelete_RemovesAndHandlesUnknownID(t *testing.T) {
setTrueNASFeatureForTest(t, true)
setMockModeForTrueNASTest(t, false)
@ -449,3 +534,27 @@ func marshalTrueNASRequest(t *testing.T, payload map[string]any) []byte {
}
return body
}
func trueNASInstanceFromRawURL(t *testing.T, id string, rawURL string, enabled bool) config.TrueNASInstance {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("url.Parse(%q): %v", rawURL, err)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil {
t.Fatalf("parse port from %q: %v", rawURL, err)
}
return config.TrueNASInstance{
ID: id,
Name: "connection-" + id,
Host: parsed.Hostname(),
Port: port,
APIKey: "test-api-key",
UseHTTPS: strings.EqualFold(parsed.Scheme, "https"),
Enabled: enabled,
PollIntervalSecs: 60,
}
}

View file

@ -8,6 +8,7 @@ import (
)
const trueNASSensitiveMask = "********"
const defaultTrueNASPollIntervalSecs = 60
// IsTrueNASSensitiveMask reports whether the value is the redacted placeholder
// used by the TrueNAS settings API.
@ -37,7 +38,28 @@ func NewTrueNASInstance() TrueNASInstance {
ID: uuid.NewString(),
UseHTTPS: true,
Enabled: true,
PollIntervalSecs: 60,
PollIntervalSecs: defaultTrueNASPollIntervalSecs,
}
}
// EffectivePollIntervalSecs returns the configured poll interval or the
// canonical default when the stored config still uses the zero-value legacy
// form.
func (t TrueNASInstance) EffectivePollIntervalSecs() int {
if t.PollIntervalSecs > 0 {
return t.PollIntervalSecs
}
return defaultTrueNASPollIntervalSecs
}
// ApplyDefaults normalizes legacy zero-value config onto the canonical stored
// defaults without changing explicitly configured values.
func (t *TrueNASInstance) ApplyDefaults() {
if t == nil {
return
}
if t.PollIntervalSecs <= 0 {
t.PollIntervalSecs = defaultTrueNASPollIntervalSecs
}
}

View file

@ -27,6 +27,17 @@ func TestTrueNASNewInstanceDefaultsAndUniqueID(t *testing.T) {
require.Equal(t, 60, first.PollIntervalSecs)
}
func TestTrueNASEffectivePollIntervalAndApplyDefaults(t *testing.T) {
instance := TrueNASInstance{}
require.Equal(t, 60, instance.EffectivePollIntervalSecs())
instance.ApplyDefaults()
require.Equal(t, 60, instance.PollIntervalSecs)
instance.PollIntervalSecs = 120
require.Equal(t, 120, instance.EffectivePollIntervalSecs())
}
func TestTrueNASValidate(t *testing.T) {
tests := []struct {
name string

View file

@ -25,6 +25,54 @@ const defaultTrueNASPollInterval = 60 * time.Second
const defaultTrueNASHistoryReadTimeout = 10 * time.Second
// TrueNASConnectionPollError captures the last runtime poll error for one
// configured TrueNAS connection.
type TrueNASConnectionPollError struct {
At *time.Time `json:"at,omitempty"`
Message string `json:"message,omitempty"`
Category string `json:"category,omitempty"`
}
// TrueNASConnectionPollStatus summarizes the runtime polling state for one
// configured TrueNAS connection.
type TrueNASConnectionPollStatus struct {
IntervalSeconds int `json:"intervalSeconds"`
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
LastError *TrueNASConnectionPollError `json:"lastError,omitempty"`
}
// TrueNASConnectionObservedSummary summarizes the canonical resources the most
// recent successful poll contributed for one configured TrueNAS connection.
type TrueNASConnectionObservedSummary struct {
Host string `json:"host,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
CollectedAt *time.Time `json:"collectedAt,omitempty"`
Systems int `json:"systems"`
StoragePools int `json:"storagePools"`
Datasets int `json:"datasets"`
Apps int `json:"apps"`
Disks int `json:"disks"`
RecoveryArtifacts int `json:"recoveryArtifacts"`
}
// TrueNASConnectionSummary merges poll health with the most recent discovered
// platform contribution for one configured TrueNAS connection.
type TrueNASConnectionSummary struct {
Poll *TrueNASConnectionPollStatus `json:"poll,omitempty"`
Observed *TrueNASConnectionObservedSummary `json:"observed,omitempty"`
}
type trueNASConnectionRuntimeStatus struct {
lastAttemptAt time.Time
lastSuccessAt time.Time
lastError *TrueNASConnectionPollError
consecutiveFailures int
nextPollAt time.Time
observed *TrueNASConnectionObservedSummary
}
// TrueNASPoller manages periodic polling of configured TrueNAS connections.
type TrueNASPoller struct {
multiTenant *config.MultiTenantPersistence
@ -34,11 +82,14 @@ type TrueNASPoller struct {
providersByOrg map[string]map[string]*truenas.Provider
// configsByOrg is keyed by orgID, then connection ID.
configsByOrg map[string]map[string]config.TrueNASInstance
// statusByOrg is keyed by orgID, then connection ID.
statusByOrg map[string]map[string]*trueNASConnectionRuntimeStatus
// cachedRecordsByOrg is keyed by orgID, then connection ID.
cachedRecordsByOrg map[string]map[string][]unifiedresources.IngestRecord
cancel context.CancelFunc
stopped chan struct{}
interval time.Duration
explicitInterval bool
}
type trueNASPollerProviderEntry struct {
@ -48,6 +99,7 @@ type trueNASPollerProviderEntry struct {
// NewTrueNASPoller builds a new TrueNAS poller with the provided poll interval.
func NewTrueNASPoller(multiTenant *config.MultiTenantPersistence, interval time.Duration, recoveryManager *recoverymanager.Manager) *TrueNASPoller {
explicitInterval := interval > 0
if interval <= 0 {
interval = defaultTrueNASPollInterval
}
@ -60,9 +112,11 @@ func NewTrueNASPoller(multiTenant *config.MultiTenantPersistence, interval time.
recoveryManager: recoveryManager,
providersByOrg: make(map[string]map[string]*truenas.Provider),
configsByOrg: make(map[string]map[string]config.TrueNASInstance),
statusByOrg: make(map[string]map[string]*trueNASConnectionRuntimeStatus),
cachedRecordsByOrg: make(map[string]map[string][]unifiedresources.IngestRecord),
stopped: stopped,
interval: interval,
explicitInterval: explicitInterval,
}
}
@ -99,15 +153,19 @@ func (p *TrueNASPoller) Start(ctx context.Context) {
p.syncConnections()
p.pollAll(runCtx)
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
wait := p.nextWaitDuration(time.Now())
timer := time.NewTimer(wait)
select {
case <-runCtx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
case <-ticker.C:
case <-timer.C:
p.syncConnections()
p.pollAll(runCtx)
}
@ -171,6 +229,7 @@ func (p *TrueNASPoller) syncConnections() {
}
// orgID -> connID -> instance
configured := make(map[string]map[string]config.TrueNASInstance, len(orgs))
active := make(map[string]map[string]config.TrueNASInstance, len(orgs))
for _, org := range orgs {
@ -206,7 +265,15 @@ func (p *TrueNASPoller) syncConnections() {
for i := range instances {
instance := instances[i]
id := strings.TrimSpace(instance.ID)
if id == "" || !instance.Enabled {
if id == "" {
continue
}
instance.ApplyDefaults()
if configured[orgID] == nil {
configured[orgID] = make(map[string]config.TrueNASInstance)
}
configured[orgID][id] = instance
if !instance.Enabled {
continue
}
if active[orgID] == nil {
@ -230,13 +297,19 @@ func (p *TrueNASPoller) syncConnections() {
if p.configsByOrg[orgID] == nil {
p.configsByOrg[orgID] = make(map[string]config.TrueNASInstance)
}
if p.statusByOrg[orgID] == nil {
p.statusByOrg[orgID] = make(map[string]*trueNASConnectionRuntimeStatus)
}
if p.cachedRecordsByOrg[orgID] == nil {
p.cachedRecordsByOrg[orgID] = make(map[string][]unifiedresources.IngestRecord)
}
for connID, instance := range byConn {
previousConfig := p.configsByOrg[orgID][connID]
existingProvider, exists := p.providersByOrg[orgID][connID]
if exists && !trueNASProviderConfigChanged(p.configsByOrg[orgID][connID], instance) {
p.configsByOrg[orgID][connID] = instance
p.alignConnectionNextPollLocked(orgID, connID, previousConfig, instance)
if exists && !trueNASProviderConfigChanged(previousConfig, instance) {
continue
}
@ -251,6 +324,8 @@ func (p *TrueNASPoller) syncConnections() {
Fingerprint: instance.Fingerprint,
})
if err != nil {
now := time.Now().UTC()
p.recordConnectionFailureLocked(orgID, connID, instance, err, now)
log.Warn().
Str("component", "truenas_poller").
Str("action", "initialize_client").
@ -265,7 +340,6 @@ func (p *TrueNASPoller) syncConnections() {
existingProvider.Close()
}
p.providersByOrg[orgID][connID] = truenas.NewLiveProvider(&truenas.APIFetcher{Client: client})
p.configsByOrg[orgID][connID] = instance
}
}
@ -307,6 +381,23 @@ func (p *TrueNASPoller) syncConnections() {
delete(p.cachedRecordsByOrg, orgID)
}
}
// Prune runtime status only when the connection is truly gone from config.
for orgID, byConn := range p.statusByOrg {
configuredByConn := configured[orgID]
for connID := range byConn {
if configuredByConn == nil {
delete(byConn, connID)
continue
}
if _, ok := configuredByConn[connID]; !ok {
delete(byConn, connID)
}
}
if len(byConn) == 0 {
delete(p.statusByOrg, orgID)
}
}
}
func (p *TrueNASPoller) closeAllProviders() {
@ -326,6 +417,77 @@ func (p *TrueNASPoller) closeAllProviders() {
}
}
// ConnectionSummaries returns per-connection runtime health and discovered
// contribution summaries for the supplied TrueNAS settings records.
func (p *TrueNASPoller) ConnectionSummaries(orgID string, instances []config.TrueNASInstance) map[string]TrueNASConnectionSummary {
if len(instances) == 0 {
return nil
}
orgID = normalizeTrueNASOrgID(orgID)
summaries := make(map[string]TrueNASConnectionSummary, len(instances))
for i := range instances {
instance := instances[i]
instance.ApplyDefaults()
connID := strings.TrimSpace(instance.ID)
if connID == "" {
continue
}
summary := TrueNASConnectionSummary{
Poll: &TrueNASConnectionPollStatus{
IntervalSeconds: instance.EffectivePollIntervalSecs(),
},
}
if p != nil {
p.mu.Lock()
status := p.cloneRuntimeStatusLocked(orgID, connID)
p.mu.Unlock()
if status != nil {
summary.Poll.LastAttemptAt = cloneTimePointer(status.LastAttemptAt)
summary.Poll.LastSuccessAt = cloneTimePointer(status.LastSuccessAt)
summary.Poll.ConsecutiveFailures = status.ConsecutiveFailures
summary.Poll.LastError = cloneTrueNASConnectionPollError(status.LastError)
summary.Observed = cloneTrueNASObservedSummary(status.Observed)
}
}
summaries[connID] = summary
}
return summaries
}
func (p *TrueNASPoller) nextWaitDuration(now time.Time) time.Duration {
if p == nil {
return defaultTrueNASPollInterval
}
p.mu.Lock()
defer p.mu.Unlock()
wait := p.interval
for orgID, byConn := range p.configsByOrg {
for connID, instance := range byConn {
next := p.nextPollAtLocked(orgID, connID, instance)
if next.IsZero() || !next.After(now) {
return 0
}
until := next.Sub(now)
if until < wait {
wait = until
}
}
}
if wait < 0 {
return 0
}
return wait
}
func (p *TrueNASPoller) pollAll(ctx context.Context) {
if p == nil {
return
@ -334,12 +496,26 @@ func (p *TrueNASPoller) pollAll(ctx context.Context) {
type providerEntry struct {
orgID string
id string
config config.TrueNASInstance
provider *truenas.Provider
}
entries := make([]providerEntry, 0)
now := time.Now().UTC()
for orgID, providers := range p.providersByOrg {
for id, provider := range providers {
entries = append(entries, providerEntry{orgID: orgID, id: id, provider: provider})
instance, ok := p.configsByOrg[orgID][id]
if !ok {
continue
}
if !p.connectionPollDueLocked(orgID, id, instance, now) {
continue
}
entries = append(entries, providerEntry{
orgID: orgID,
id: id,
config: instance,
provider: provider,
})
}
}
p.mu.Unlock()
@ -351,9 +527,9 @@ func (p *TrueNASPoller) pollAll(ctx context.Context) {
continue
}
start := time.Now()
start := time.Now().UTC()
err := entry.provider.Refresh(ctx)
end := time.Now()
end := time.Now().UTC()
if err != nil {
pm.RecordResult(PollResult{
InstanceName: entry.id,
@ -363,6 +539,9 @@ func (p *TrueNASPoller) pollAll(ctx context.Context) {
StartTime: start,
EndTime: end,
})
p.mu.Lock()
p.recordConnectionFailureLocked(entry.orgID, entry.id, entry.config, err, end)
p.mu.Unlock()
log.Warn().
Str("component", "truenas_poller").
Str("action", "refresh_connection").
@ -380,6 +559,11 @@ func (p *TrueNASPoller) pollAll(ctx context.Context) {
EndTime: end,
})
snapshot := entry.provider.Snapshot()
p.mu.Lock()
p.recordConnectionSuccessLocked(entry.orgID, entry.id, entry.config, end, snapshot)
p.mu.Unlock()
records := entry.provider.Records()
if len(records) == 0 {
p.mu.Lock()
@ -401,6 +585,212 @@ func (p *TrueNASPoller) pollAll(ctx context.Context) {
}
}
type trueNASConnectionRuntimeSnapshot struct {
LastAttemptAt *time.Time
LastSuccessAt *time.Time
ConsecutiveFailures int
LastError *TrueNASConnectionPollError
Observed *TrueNASConnectionObservedSummary
}
func (p *TrueNASPoller) cloneRuntimeStatusLocked(orgID string, connID string) *trueNASConnectionRuntimeSnapshot {
if p == nil {
return nil
}
byConn := p.statusByOrg[orgID]
if byConn == nil {
return nil
}
status := byConn[connID]
if status == nil {
return nil
}
return &trueNASConnectionRuntimeSnapshot{
LastAttemptAt: timePointerIfSet(status.lastAttemptAt),
LastSuccessAt: timePointerIfSet(status.lastSuccessAt),
ConsecutiveFailures: status.consecutiveFailures,
LastError: cloneTrueNASConnectionPollError(status.lastError),
Observed: cloneTrueNASObservedSummary(status.observed),
}
}
func (p *TrueNASPoller) ensureConnectionRuntimeStatusLocked(orgID, connID string) *trueNASConnectionRuntimeStatus {
if p.statusByOrg[orgID] == nil {
p.statusByOrg[orgID] = make(map[string]*trueNASConnectionRuntimeStatus)
}
if p.statusByOrg[orgID][connID] == nil {
p.statusByOrg[orgID][connID] = &trueNASConnectionRuntimeStatus{}
}
return p.statusByOrg[orgID][connID]
}
func (p *TrueNASPoller) alignConnectionNextPollLocked(
orgID string,
connID string,
previous config.TrueNASInstance,
next config.TrueNASInstance,
) {
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
if status == nil || status.lastAttemptAt.IsZero() {
return
}
previousInterval := p.effectiveRuntimePollInterval(previous)
nextInterval := p.effectiveRuntimePollInterval(next)
if previousInterval == nextInterval {
return
}
status.nextPollAt = status.lastAttemptAt.Add(nextInterval)
}
func (p *TrueNASPoller) connectionPollDueLocked(
orgID string,
connID string,
instance config.TrueNASInstance,
now time.Time,
) bool {
next := p.nextPollAtLocked(orgID, connID, instance)
return next.IsZero() || !next.After(now)
}
func (p *TrueNASPoller) nextPollAtLocked(orgID string, connID string, instance config.TrueNASInstance) time.Time {
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
if status == nil {
return time.Time{}
}
if status.nextPollAt.IsZero() && !status.lastAttemptAt.IsZero() {
status.nextPollAt = status.lastAttemptAt.Add(p.effectiveRuntimePollInterval(instance))
}
return status.nextPollAt
}
func (p *TrueNASPoller) effectiveRuntimePollInterval(instance config.TrueNASInstance) time.Duration {
base := time.Duration(instance.EffectivePollIntervalSecs()) * time.Second
if p != nil && p.explicitInterval && p.interval > 0 && p.interval < base {
return p.interval
}
return base
}
func (p *TrueNASPoller) recordConnectionSuccessLocked(
orgID string,
connID string,
instance config.TrueNASInstance,
at time.Time,
snapshot *truenas.FixtureSnapshot,
) {
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
status.lastAttemptAt = at
status.lastSuccessAt = at
status.lastError = nil
status.consecutiveFailures = 0
status.nextPollAt = at.Add(p.effectiveRuntimePollInterval(instance))
status.observed = buildTrueNASObservedSummary(snapshot)
}
func (p *TrueNASPoller) recordConnectionFailureLocked(
orgID string,
connID string,
instance config.TrueNASInstance,
err error,
at time.Time,
) {
status := p.ensureConnectionRuntimeStatusLocked(orgID, connID)
status.lastAttemptAt = at
status.consecutiveFailures++
status.nextPollAt = at.Add(p.effectiveRuntimePollInterval(instance))
category := ""
if monitorErr := classifyTrueNASError(err, connID); monitorErr != nil {
category = string(monitorErr.Type)
}
status.lastError = &TrueNASConnectionPollError{
At: timePointerIfSet(at),
Message: strings.TrimSpace(err.Error()),
Category: category,
}
}
func buildTrueNASObservedSummary(snapshot *truenas.FixtureSnapshot) *TrueNASConnectionObservedSummary {
if snapshot == nil {
return nil
}
host := strings.TrimSpace(snapshot.System.Hostname)
resourceID := host
collectedAt := snapshot.CollectedAt
if collectedAt.IsZero() {
collectedAt = snapshot.System.CollectedAt
}
summary := &TrueNASConnectionObservedSummary{
Host: host,
ResourceID: resourceID,
Systems: 0,
StoragePools: len(snapshot.Pools),
Datasets: len(snapshot.Datasets),
Apps: len(snapshot.Apps),
Disks: len(snapshot.Disks),
RecoveryArtifacts: len(snapshot.ZFSSnapshots) + len(snapshot.ReplicationTasks),
}
if host != "" || resourceID != "" {
summary.Systems = 1
}
summary.CollectedAt = timePointerIfSet(collectedAt)
return summary
}
func normalizeTrueNASOrgID(orgID string) string {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return "default"
}
return orgID
}
func timePointerIfSet(value time.Time) *time.Time {
if value.IsZero() {
return nil
}
copied := value
return &copied
}
func cloneTimePointer(value *time.Time) *time.Time {
if value == nil || value.IsZero() {
return nil
}
copied := *value
return &copied
}
func cloneTrueNASConnectionPollError(value *TrueNASConnectionPollError) *TrueNASConnectionPollError {
if value == nil {
return nil
}
return &TrueNASConnectionPollError{
At: cloneTimePointer(value.At),
Message: strings.TrimSpace(value.Message),
Category: strings.TrimSpace(value.Category),
}
}
func cloneTrueNASObservedSummary(value *TrueNASConnectionObservedSummary) *TrueNASConnectionObservedSummary {
if value == nil {
return nil
}
return &TrueNASConnectionObservedSummary{
Host: strings.TrimSpace(value.Host),
ResourceID: strings.TrimSpace(value.ResourceID),
CollectedAt: cloneTimePointer(value.CollectedAt),
Systems: value.Systems,
StoragePools: value.StoragePools,
Datasets: value.Datasets,
Apps: value.Apps,
Disks: value.Disks,
RecoveryArtifacts: value.RecoveryArtifacts,
}
}
func (p *TrueNASPoller) ingestRecoveryPoints(ctx context.Context, orgID string, connectionID string, provider *truenas.Provider) {
if p == nil || p.recoveryManager == nil || provider == nil {
return

View file

@ -249,6 +249,116 @@ func TestTrueNASPollerRecordsMetrics(t *testing.T) {
}
}
func TestTrueNASPollerConnectionSummariesExposeObservedCounts(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() { truenas.SetFeatureEnabled(previous) })
mock := newTrueNASMockServer(t, "summary-host")
t.Cleanup(mock.Close)
mtp, persistence := newTestTenantPersistence(t)
connection := trueNASInstanceForServer(t, "summary-conn", mock.URL(), true)
if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil {
t.Fatalf("SaveTrueNASConfig() error = %v", err)
}
poller := NewTrueNASPoller(mtp, 50*time.Millisecond, nil)
poller.Start(context.Background())
t.Cleanup(poller.Stop)
waitForCondition(t, 2*time.Second, func() bool {
summaries := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})
summary, ok := summaries[connection.ID]
return ok && summary.Poll != nil && summary.Poll.LastSuccessAt != nil && summary.Observed != nil
}, "expected connection summary to include successful poll and observed counts")
summary := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})[connection.ID]
if summary.Poll == nil || summary.Poll.IntervalSeconds != 60 {
t.Fatalf("expected poll interval summary 60 seconds, got %+v", summary.Poll)
}
if summary.Observed == nil {
t.Fatal("expected observed summary to be present")
}
if summary.Observed.Host != "summary-host" || summary.Observed.ResourceID != "summary-host" {
t.Fatalf("unexpected observed host identity: %+v", summary.Observed)
}
if summary.Observed.Systems != 1 || summary.Observed.StoragePools != 1 || summary.Observed.Datasets != 1 || summary.Observed.Disks != 1 {
t.Fatalf("unexpected observed counts: %+v", summary.Observed)
}
}
func TestTrueNASPollerConnectionSummariesCaptureFailures(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() { truenas.SetFeatureEnabled(previous) })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}))
t.Cleanup(server.Close)
mtp, persistence := newTestTenantPersistence(t)
connection := trueNASInstanceForServer(t, "summary-fail", server.URL, true)
if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil {
t.Fatalf("SaveTrueNASConfig() error = %v", err)
}
poller := NewTrueNASPoller(mtp, 50*time.Millisecond, nil)
poller.Start(context.Background())
t.Cleanup(poller.Stop)
waitForCondition(t, 2*time.Second, func() bool {
summaries := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})
summary, ok := summaries[connection.ID]
return ok && summary.Poll != nil && summary.Poll.LastError != nil && summary.Poll.ConsecutiveFailures > 0
}, "expected connection summary to capture poll failure state")
summary := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})[connection.ID]
if summary.Poll == nil || summary.Poll.LastError == nil {
t.Fatalf("expected poll failure summary, got %+v", summary.Poll)
}
if summary.Poll.LastError.Category != "auth" {
t.Fatalf("expected auth error category, got %+v", summary.Poll.LastError)
}
}
func TestTrueNASPollerHonorsConfiguredPollInterval(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() { truenas.SetFeatureEnabled(previous) })
mock := newTrueNASMockServer(t, "interval-host")
t.Cleanup(mock.Close)
mtp, persistence := newTestTenantPersistence(t)
connection := trueNASInstanceForServer(t, "interval-conn", mock.URL(), true)
connection.PollIntervalSecs = 1
if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil {
t.Fatalf("SaveTrueNASConfig() error = %v", err)
}
poller := NewTrueNASPoller(mtp, 0, nil)
poller.Start(context.Background())
t.Cleanup(poller.Stop)
waitForCondition(t, 2*time.Second, func() bool {
return mock.RequestCount() >= 5
}, "expected initial immediate poll for configured TrueNAS connection")
requestCountAfterFirstPoll := mock.RequestCount()
time.Sleep(400 * time.Millisecond)
if got := mock.RequestCount(); got != requestCountAfterFirstPoll {
t.Fatalf("expected configured 1s poll interval to avoid an early repoll, got before=%d after=%d", requestCountAfterFirstPoll, got)
}
waitForCondition(t, 2*time.Second, func() bool {
return mock.RequestCount() > requestCountAfterFirstPoll
}, "expected configured 1s poll interval to trigger the next poll without waiting for the 60s default")
}
func TestTrueNASPollerPhysicalDiskTemperatureHistoryUsesTenantScopedProvider(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)

View file

@ -54,6 +54,9 @@ test.describe('TrueNAS platform connections settings', () => {
test('renders the platform-connections workspace with the TrueNAS integration shell', async ({
page,
}) => {
const healthyAt = new Date(Date.now() - 5 * 60_000).toISOString();
const failingAt = new Date(Date.now() - 2 * 60_000).toISOString();
await page.route('**/api/truenas/connections', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
@ -74,6 +77,22 @@ test.describe('TrueNAS platform connections settings', () => {
insecureSkipVerify: false,
fingerprint: '',
enabled: true,
pollIntervalSeconds: 60,
poll: {
intervalSeconds: 60,
lastSuccessAt: healthyAt,
},
observed: {
host: 'tower',
resourceId: 'tower',
collectedAt: healthyAt,
systems: 1,
storagePools: 2,
datasets: 12,
apps: 4,
disks: 8,
recoveryArtifacts: 18,
},
},
{
id: 'truenas-2',
@ -86,6 +105,28 @@ test.describe('TrueNAS platform connections settings', () => {
insecureSkipVerify: true,
fingerprint: 'sha256:example',
enabled: false,
pollIntervalSeconds: 300,
poll: {
intervalSeconds: 300,
lastAttemptAt: failingAt,
consecutiveFailures: 2,
lastError: {
at: failingAt,
message: 'authentication failed',
category: 'auth',
},
},
observed: {
host: 'vault',
resourceId: 'vault',
collectedAt: healthyAt,
systems: 1,
storagePools: 1,
datasets: 6,
apps: 0,
disks: 12,
recoveryArtifacts: 24,
},
},
]),
});
@ -120,6 +161,28 @@ test.describe('TrueNAS platform connections settings', () => {
await expect(page.getByText('Backup Vault')).toBeVisible();
await expect(page.getByText('API key auth')).toBeVisible();
await expect(page.getByText('Username/password auth')).toBeVisible();
await expect(page.getByText('Healthy')).toBeVisible();
await expect(page.getByText('Paused', { exact: true })).toBeVisible();
await expect(page.getByText('Poll every 1 minute')).toBeVisible();
await expect(page.getByText('Poll every 5 minutes')).toBeVisible();
await expect(page.getByText('2 pools')).toBeVisible();
await expect(page.getByText('12 datasets')).toBeVisible();
await expect(page.getByTestId('truenas-connection-truenas-1-infrastructure')).toHaveAttribute(
'href',
'/infrastructure?source=truenas&resource=tower',
);
await expect(page.getByTestId('truenas-connection-truenas-1-workloads')).toHaveAttribute(
'href',
'/workloads?type=app-container&platform=truenas&agent=tower',
);
await expect(page.getByTestId('truenas-connection-truenas-1-storage')).toHaveAttribute(
'href',
'/storage?source=truenas&node=tower',
);
await expect(page.getByTestId('truenas-connection-truenas-1-recovery')).toHaveAttribute(
'href',
'/recovery?platform=truenas&node=tower',
);
fs.mkdirSync(path.dirname(SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });