Migrate TrueNAS to supported JSON-RPC transport

This commit is contained in:
rcourtman 2026-07-24 00:07:19 +01:00
parent 0c17922055
commit b81ba7dd06
19 changed files with 2145 additions and 371 deletions

View file

@ -19,6 +19,7 @@ describe('TrueNASAPI', () => {
host: ' truenas.local ',
port: 443,
apiKey: ' ******** ',
username: ' pulse-readonly ',
useHttps: true,
insecureSkipVerify: false,
enabled: true,
@ -40,6 +41,15 @@ describe('TrueNASAPI', () => {
disks: 8,
recoveryArtifacts: 18,
},
transport: {
mode: 'jsonrpc-websocket',
endpoint: ' wss://truenas.local/api/current ',
tls: true,
connected: true,
authMechanism: ' api-key-plain ',
applianceVersion: ' TrueNAS-SCALE-25.10.4 ',
reconnects: 1,
},
},
]);
@ -50,7 +60,7 @@ describe('TrueNASAPI', () => {
host: 'truenas.local',
port: 443,
apiKey: '********',
username: undefined,
username: 'pulse-readonly',
password: undefined,
useHttps: true,
insecureSkipVerify: false,
@ -77,6 +87,18 @@ describe('TrueNASAPI', () => {
disks: 8,
recoveryArtifacts: 18,
},
transport: {
mode: 'jsonrpc-websocket',
endpoint: 'wss://truenas.local/api/current',
tls: true,
connected: true,
authMechanism: 'api-key-plain',
applianceVersion: 'TrueNAS-SCALE-25.10.4',
legacyReason: undefined,
reconnects: 1,
lastError: undefined,
lastConnectedAt: undefined,
},
monitorDatasets: false,
monitorPools: false,
monitorReplication: false,

View file

@ -21,6 +21,7 @@ type RawTrueNASConnection = Partial<TrueNASConnection>;
type RawTrueNASConnectionPollError = Partial<TrueNASConnectionPollError>;
type RawTrueNASConnectionPoll = Partial<TrueNASConnectionPollStatus>;
type RawTrueNASConnectionObservedSummary = Partial<TrueNASConnectionObservedSummary>;
type RawTrueNASConnectionTransportStatus = Partial<TrueNASConnectionTransportStatus>;
export interface TrueNASConnectionPollError {
at?: string;
@ -50,6 +51,19 @@ export interface TrueNASConnectionObservedSummary {
recoveryArtifacts: number;
}
export interface TrueNASConnectionTransportStatus {
mode: 'negotiating' | 'jsonrpc-websocket' | 'legacy-rest';
endpoint?: string;
tls: boolean;
connected: boolean;
authMechanism?: string;
applianceVersion?: string;
legacyReason?: string;
reconnects?: number;
lastError?: string;
lastConnectedAt?: string;
}
export interface TrueNASConnection {
id: string;
name: string;
@ -68,6 +82,7 @@ export interface TrueNASConnection {
monitorReplication: boolean;
poll?: TrueNASConnectionPollStatus;
observed?: TrueNASConnectionObservedSummary;
transport?: TrueNASConnectionTransportStatus;
}
export interface TrueNASConnectionInput {
@ -134,6 +149,27 @@ const normalizeTrueNASConnectionObservedSummary = (
};
};
const normalizeTrueNASConnectionTransportStatus = (
transport: RawTrueNASConnectionTransportStatus | undefined,
): TrueNASConnectionTransportStatus | undefined => {
if (!transport || typeof transport !== 'object') return undefined;
const rawMode = optionalTrimmedString(transport.mode);
const mode =
rawMode === 'jsonrpc-websocket' || rawMode === 'legacy-rest' ? rawMode : 'negotiating';
return {
mode,
endpoint: optionalTrimmedString(transport.endpoint),
tls: strictBoolean(transport.tls),
connected: strictBoolean(transport.connected),
authMechanism: optionalTrimmedString(transport.authMechanism),
applianceVersion: optionalTrimmedString(transport.applianceVersion),
legacyReason: optionalTrimmedString(transport.legacyReason),
reconnects: finiteNumberOrUndefined(transport.reconnects),
lastError: optionalTrimmedString(transport.lastError),
lastConnectedAt: optionalTrimmedString(transport.lastConnectedAt),
};
};
const normalizeTrueNASConnection = (connection: RawTrueNASConnection): TrueNASConnection => ({
id: trimmedString(connection.id),
name: optionalTrimmedString(connection.name) ?? '',
@ -152,6 +188,7 @@ const normalizeTrueNASConnection = (connection: RawTrueNASConnection): TrueNASCo
monitorReplication: strictBoolean(connection.monitorReplication),
poll: normalizeTrueNASConnectionPoll(connection.poll),
observed: normalizeTrueNASConnectionObservedSummary(connection.observed),
transport: normalizeTrueNASConnectionTransportStatus(connection.transport),
});
const serializeTrueNASConnectionInput = (input: TrueNASConnectionInput) => ({

View file

@ -28,6 +28,22 @@ export interface TrueNASCredentialSlotProps {
deleteError?: string | null;
}
const describeTransport = (transport: NonNullable<TrueNASConnection['transport']>): string => {
let description: string;
if (transport.mode === 'jsonrpc-websocket') {
description = `Transport: JSON-RPC WebSocket${transport.tls ? ' over TLS' : ''}; authentication: ${transport.authMechanism || 'session'}.`;
} else if (transport.mode === 'legacy-rest') {
description =
`Transport: legacy REST (${transport.applianceVersion || 'legacy TrueNAS'}). ${transport.legacyReason || ''}`.trim();
} else {
description = 'Transport negotiation has not completed yet.';
}
if (transport.lastError) {
description += ` Last error: ${transport.lastError}`;
}
return description;
};
export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (props) => {
let primed = false;
@ -132,21 +148,36 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
</div>
<Show when={props.state.form().authMode === 'apiKey'}>
<label class={formField}>
<span class={formLabel}>API key</span>
<input
class={formControl}
type="password"
value={props.state.form().apiKey}
onInput={(event) => props.state.updateForm({ apiKey: event.currentTarget.value })}
placeholder={
props.state.form().hasStoredApiKey ? 'Saved API key retained unless replaced' : ''
}
/>
<Show when={props.state.form().hasStoredApiKey}>
<span class={formHelpText}>Leave this blank to keep the saved API key.</span>
</Show>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>API key owner username</span>
<input
class={formControl}
value={props.state.form().username}
onInput={(event) => props.state.updateForm({ username: event.currentTarget.value })}
placeholder="pulse-monitor"
/>
<span class={formHelpText}>
Use the TrueNAS user that owns this key. Required by the supported login flow on
current and future releases.
</span>
</label>
<label class={formField}>
<span class={formLabel}>API key</span>
<input
class={formControl}
type="password"
value={props.state.form().apiKey}
onInput={(event) => props.state.updateForm({ apiKey: event.currentTarget.value })}
placeholder={
props.state.form().hasStoredApiKey ? 'Saved API key retained unless replaced' : ''
}
/>
<Show when={props.state.form().hasStoredApiKey}>
<span class={formHelpText}>Leave this blank to keep the saved API key.</span>
</Show>
</label>
</div>
</Show>
<Show when={props.state.form().authMode === 'userpass'}>
@ -236,6 +267,17 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
</div>
</div>
<Show when={props.editingConnection?.transport}>
{(transport) => (
<CalloutCard
tone={transport().mode === 'legacy-rest' ? 'warning' : 'info'}
scale="compact"
padding="sm"
description={describeTransport(transport())}
/>
)}
</Show>
<div class="space-y-3 rounded-md border border-border bg-surface p-4">
<div>
<div class="text-sm font-semibold text-base-content">Collection scope</div>

View file

@ -80,6 +80,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
username: 'pulse-readonly',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
@ -91,6 +92,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
username: 'pulse-readonly',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
@ -105,6 +107,7 @@ describe('useTrueNASSettingsPanelState', () => {
name: 'tower',
host: 'truenas.local',
apiKey: '********',
username: 'pulse-readonly',
pollIntervalSeconds: 90,
useHttps: true,
insecureSkipVerify: false,
@ -128,6 +131,7 @@ describe('useTrueNASSettingsPanelState', () => {
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
username: 'pulse-readonly',
}),
);
expect(TrueNASAPI.updateConnection).toHaveBeenCalledWith(
@ -136,7 +140,7 @@ describe('useTrueNASSettingsPanelState', () => {
host: 'truenas.local',
apiKey: '********',
pollIntervalSeconds: 90,
username: '',
username: 'pulse-readonly',
password: '',
}),
);

View file

@ -73,7 +73,7 @@ const buildFormStateFromConnection = (
pollIntervalSeconds: String(connection.pollIntervalSeconds ?? 60),
authMode,
apiKey: '',
username: authMode === 'userpass' ? connection.username || '' : '',
username: connection.username || '',
password: '',
useHttps: connection.useHttps,
insecureSkipVerify: connection.insecureSkipVerify,
@ -163,7 +163,7 @@ const buildConnectionInput = (form: TrueNASConnectionFormState): TrueNASConnecti
if (form.authMode === 'apiKey') {
input.apiKey = form.apiKey.trim() || (form.hasStoredApiKey ? REDACTED_SECRET : '');
input.username = '';
input.username = form.username.trim();
input.password = '';
} else {
input.apiKey = '';