v6(settings): phase 6b — TrueNAS and VMware credential slots inside ConnectionEditor

Extracts the inner form bodies from TrueNASSettingsPanel and
VMwareSettingsPanel into dedicated credential slot components mounted
inline under ConnectionEditor. When the user adds a TrueNAS or VMware
connection, the editor now shows a clean form instead of re-rendering
the full platform panel (which listed every other saved connection
before exposing the add dialog).

State still lives on TrueNASSettingsPanelState and
VMwareSettingsPanelState, so save, test, preview, and admission-preview
behavior remain identical. The slots prime the existing create-dialog
state on mount and close it on cancel; save completion is detected via
the state's dialogOpen transition.
This commit is contained in:
rcourtman
2026-04-19 13:43:25 +01:00
parent 4a587ba544
commit 5dd6ea88cb
6 changed files with 530 additions and 16 deletions
@@ -331,11 +331,19 @@ an add-only capacity posture.
`NodeModalAuthenticationSection`, `NodeModalMonitoringSection`, and
`NodeModalStatusFooter` primitives inline under the editor — dropping
the Dialog wrapper and the surrounding discovery/configured-nodes
workspace. The add flow must not reintroduce the full Proxmox
workspace (discovery card, configured nodes table, node-modal stack)
into the credential slot, because that previously showed the
ledger-of-other-systems in the middle of entering one system's
credentials.
workspace. For TrueNAS and VMware, the credential slots are
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`
and
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`;
they extract the inner form bodies from the per-type panels and
render them inline under the editor while still driving the existing
`TrueNASSettingsPanelState` and `VMwareSettingsPanelState` APIs for
save, test, preview, and admission-preview behavior. The add flow
must not reintroduce the full per-type workspace (Proxmox discovery
card, configured nodes table, node-modal stack; TrueNAS/VMware
connection list with headers and row actions) into the credential
slot, because that previously showed the ledger-of-other-systems in
the middle of entering one system's credentials.
## Forbidden Paths
@@ -246,9 +246,15 @@ work extends shared components instead of creating new local variants.
`NodeModalAuthenticationSection`, `NodeModalMonitoringSection`, and
`NodeModalStatusFooter` inline under the editor shell rather than
embedding the full Proxmox workspace (discovery card, configured
nodes table, delete dialog, node modal stack). Showing a ledger of
other systems inside the credential slot is exactly the ledger-inside-
editor drift this contract forbids.
nodes table, delete dialog, node modal stack). For TrueNAS and VMware
the credential slots are
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`
and
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`
and they must render only the connection form body inline under the
editor shell — no connection list, no row actions, no surrounding
panel chrome. Showing a ledger of other systems inside the credential
slot is exactly the ledger-inside-editor drift this contract forbids.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.
7. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.
@@ -0,0 +1,275 @@
import { Component, Show, createEffect, onMount } from 'solid-js';
import {
formCheckbox,
formControl,
formField,
formHelpText,
formLabel,
formSelect,
} from '@/components/shared/Form';
import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview';
import type { TrueNASSettingsPanelState } from '../../useTrueNASSettingsPanelState';
const buttonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const primaryButtonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
export interface TrueNASCredentialSlotProps {
state: TrueNASSettingsPanelState;
onCancel: () => void;
onSaved: () => void;
}
export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (props) => {
let primed = false;
onMount(() => {
if (!props.state.dialogOpen()) {
props.state.openCreateDialog();
}
primed = true;
});
createEffect(() => {
const open = props.state.dialogOpen();
if (primed && !open && !props.state.saving()) {
props.onSaved();
}
});
const handleCancel = () => {
props.state.closeDialog();
props.onCancel();
};
return (
<div class="space-y-6">
<Show when={props.state.featureDisabled()}>
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
{props.state.featureDisabledMessage() ||
'TrueNAS connections are disabled for this Pulse tier.'}
</div>
</Show>
<Show when={!props.state.featureDisabled()}>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Name</span>
<input
class={formControl}
value={props.state.form().name}
onInput={(event) => props.state.updateForm({ name: event.currentTarget.value })}
placeholder="tower"
/>
</label>
<label class={formField}>
<span class={formLabel}>Host</span>
<input
class={formControl}
value={props.state.form().host}
onInput={(event) => props.state.updateForm({ host: event.currentTarget.value })}
placeholder="truenas.local"
/>
</label>
<label class={formField}>
<span class={formLabel}>Port</span>
<input
class={formControl}
inputMode="numeric"
value={props.state.form().port}
onInput={(event) => props.state.updateForm({ port: event.currentTarget.value })}
placeholder={props.state.form().useHttps ? '443' : '80'}
/>
</label>
<label class={formField}>
<span class={formLabel}>Poll interval (seconds)</span>
<input
class={formControl}
inputMode="numeric"
value={props.state.form().pollIntervalSeconds}
onInput={(event) =>
props.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
class={formSelect}
value={props.state.form().authMode}
onChange={(event) =>
props.state.updateForm({
authMode: event.currentTarget.value as 'apiKey' | 'userpass',
apiKey: '',
password: '',
})
}
>
<option value="apiKey">API key</option>
<option value="userpass">Username and password</option>
</select>
</label>
</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>
</Show>
<Show when={props.state.form().authMode === 'userpass'}>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Username</span>
<input
class={formControl}
value={props.state.form().username}
onInput={(event) =>
props.state.updateForm({ username: event.currentTarget.value })
}
placeholder="admin"
/>
</label>
<label class={formField}>
<span class={formLabel}>Password</span>
<input
class={formControl}
type="password"
value={props.state.form().password}
onInput={(event) =>
props.state.updateForm({ password: event.currentTarget.value })
}
placeholder={
props.state.form().hasStoredPassword
? 'Saved password retained unless replaced'
: ''
}
/>
<Show when={props.state.form().hasStoredPassword}>
<span class={formHelpText}>Leave this blank to keep the saved password.</span>
</Show>
</label>
</div>
</Show>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>TLS fingerprint</span>
<input
class={formControl}
value={props.state.form().fingerprint}
onInput={(event) =>
props.state.updateForm({ fingerprint: event.currentTarget.value })
}
placeholder="Optional SHA256 fingerprint"
/>
<span class={formHelpText}>Optional certificate pin for HTTPS connections.</span>
</label>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={props.state.form().useHttps}
onChange={(event) =>
props.state.updateForm({ useHttps: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Use HTTPS</span>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={props.state.form().insecureSkipVerify}
onChange={(event) =>
props.state.updateForm({ insecureSkipVerify: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Skip TLS verification</span>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={props.state.form().enabled}
onChange={(event) =>
props.state.updateForm({ enabled: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Enable polling immediately</span>
</label>
</div>
</div>
<MonitoredSystemAdmissionPreview
preview={props.state.monitoredSystemPreview()}
loading={props.state.previewing()}
error={props.state.monitoredSystemPreviewError()}
errorTitle={props.state.monitoredSystemPreviewErrorTitle()}
/>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
class={buttonClass}
onClick={handleCancel}
disabled={props.state.saving() || props.state.testing()}
>
Cancel
</button>
<button
type="button"
class={buttonClass}
onClick={() => void props.state.testCurrentForm()}
disabled={props.state.saving() || props.state.testing()}
>
{props.state.testing() ? 'Testing…' : 'Test connection'}
</button>
<button
type="button"
class={buttonClass}
onClick={() => void props.state.previewCurrentForm()}
disabled={
props.state.saving() || props.state.testing() || props.state.previewing()
}
>
{props.state.previewing() ? 'Previewing…' : 'Preview impact'}
</button>
<button
type="button"
class={primaryButtonClass}
onClick={() => void props.state.saveCurrentForm()}
disabled={
props.state.saving() ||
props.state.testing() ||
props.state.previewing() ||
props.state.monitoredSystemAdmissionSaveBlocked()
}
>
{props.state.saving() ? 'Adding…' : 'Add connection'}
</button>
</div>
</Show>
</div>
);
};
@@ -0,0 +1,213 @@
import { Component, Show, createEffect, onMount } from 'solid-js';
import ShieldAlert from 'lucide-solid/icons/shield-alert';
import { CalloutCard } from '@/components/shared/CalloutCard';
import {
formCheckbox,
formControl,
formField,
formHelpText,
formLabel,
} from '@/components/shared/Form';
import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview';
import type { VMwareSettingsPanelState } from '../../useVMwareSettingsPanelState';
const buttonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const primaryButtonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
export interface VMwareCredentialSlotProps {
state: VMwareSettingsPanelState;
onCancel: () => void;
onSaved: () => void;
}
export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props) => {
let primed = false;
onMount(() => {
if (!props.state.dialogOpen()) {
props.state.openCreateDialog();
}
primed = true;
});
createEffect(() => {
const open = props.state.dialogOpen();
if (primed && !open && !props.state.saving()) {
props.onSaved();
}
});
const handleCancel = () => {
props.state.closeDialog();
props.onCancel();
};
return (
<div class="space-y-6">
<Show when={props.state.featureDisabled()}>
<div class="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200">
{props.state.featureDisabledMessage() ||
'VMware connections are disabled for this Pulse tier.'}
</div>
</Show>
<Show when={!props.state.featureDisabled()}>
<Show when={props.state.connectionFailure()}>
{(failure) => (
<CalloutCard
data-testid="vmware-connection-test-feedback"
tone={failure().tone}
title={failure().title}
description={
<>
<p>{failure().message}</p>
<Show when={failure().guidance}>
<p class="mt-2">{failure().guidance}</p>
</Show>
</>
}
icon={<ShieldAlert class="h-5 w-5" />}
/>
)}
</Show>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Name</span>
<input
class={formControl}
value={props.state.form().name}
onInput={(event) => props.state.updateForm({ name: event.currentTarget.value })}
placeholder="lab-vcenter"
/>
</label>
<label class={formField}>
<span class={formLabel}>Host</span>
<input
class={formControl}
value={props.state.form().host}
onInput={(event) => props.state.updateForm({ host: event.currentTarget.value })}
placeholder="vcsa.lab.local"
/>
</label>
<label class={formField}>
<span class={formLabel}>Port</span>
<input
class={formControl}
inputMode="numeric"
value={props.state.form().port}
onInput={(event) => props.state.updateForm({ port: event.currentTarget.value })}
placeholder="443"
/>
</label>
<label class={formField}>
<span class={formLabel}>Username</span>
<input
class={formControl}
value={props.state.form().username}
onInput={(event) =>
props.state.updateForm({ username: event.currentTarget.value })
}
placeholder="administrator@vsphere.local"
/>
</label>
<label class={`${formField} sm:col-span-2`}>
<span class={formLabel}>Password</span>
<input
class={formControl}
type="password"
value={props.state.form().password}
onInput={(event) =>
props.state.updateForm({ password: event.currentTarget.value })
}
placeholder={
props.state.form().hasStoredPassword
? 'Saved password retained unless replaced'
: ''
}
/>
<Show when={props.state.form().hasStoredPassword}>
<span class={formHelpText}>Leave this blank to keep the saved password.</span>
</Show>
</label>
</div>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={props.state.form().insecureSkipVerify}
onChange={(event) =>
props.state.updateForm({ insecureSkipVerify: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Skip TLS verification</span>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={props.state.form().enabled}
onChange={(event) =>
props.state.updateForm({ enabled: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Enable this vCenter connection</span>
</label>
</div>
<MonitoredSystemAdmissionPreview
preview={props.state.monitoredSystemPreview()}
loading={props.state.previewing()}
error={props.state.monitoredSystemPreviewError()}
errorTitle={props.state.monitoredSystemPreviewErrorTitle()}
/>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
class={buttonClass}
onClick={handleCancel}
disabled={props.state.saving() || props.state.testing()}
>
Cancel
</button>
<button
type="button"
class={buttonClass}
onClick={() => void props.state.testCurrentForm()}
disabled={props.state.saving() || props.state.testing()}
>
{props.state.testing() ? 'Testing…' : 'Test connection'}
</button>
<button
type="button"
class={buttonClass}
onClick={() => void props.state.previewCurrentForm()}
disabled={
props.state.saving() || props.state.testing() || props.state.previewing()
}
>
{props.state.previewing() ? 'Previewing…' : 'Preview impact'}
</button>
<button
type="button"
class={primaryButtonClass}
onClick={() => void props.state.saveCurrentForm()}
disabled={
props.state.saving() ||
props.state.testing() ||
props.state.previewing() ||
props.state.monitoredSystemAdmissionSaveBlocked()
}
>
{props.state.saving() ? 'Adding…' : 'Add connection'}
</button>
</div>
</Show>
</div>
);
};
@@ -11,13 +11,13 @@ import {
} from './connectionsTableModel';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot';
import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot';
import { VMwareCredentialSlot } from './ConnectionEditor/CredentialSlots/VMwareCredentialSlot';
import type { ConnectionType } from '@/api/connections';
import { InfrastructureActiveRowDetails } from './InfrastructureActiveRowDetails';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureIgnoredRowDetails } from './InfrastructureIgnoredRowDetails';
import { InfrastructureStopMonitoringDialog } from './InfrastructureStopMonitoringDialog';
import { TrueNASSettingsPanel } from './TrueNASSettingsPanel';
import { VMwareSettingsPanel } from './VMwareSettingsPanel';
import {
buildInfrastructureWorkspacePath,
deriveAddStepFromLegacyPath,
@@ -178,9 +178,21 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
case 'pmg':
return renderNodeSlot(type);
case 'truenas':
return <TrueNASSettingsPanel state={props.trueNASSettings} />;
return (
<TrueNASCredentialSlot
state={props.trueNASSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'vmware':
return <VMwareSettingsPanel state={props.vmwareSettings} />;
return (
<VMwareCredentialSlot
state={props.vmwareSettings}
onCancel={exitAddMode}
onSaved={exitAddMode}
/>
);
case 'agent':
return (
<div class="space-y-4">
@@ -62,12 +62,12 @@ vi.mock('../ConnectionEditor/CredentialSlots/NodeCredentialSlot', () => ({
),
}));
vi.mock('../TrueNASSettingsPanel', () => ({
TrueNASSettingsPanel: () => <div data-testid="truenas-section">truenas</div>,
vi.mock('../ConnectionEditor/CredentialSlots/TrueNASCredentialSlot', () => ({
TrueNASCredentialSlot: () => <div data-testid="truenas-section">truenas</div>,
}));
vi.mock('../VMwareSettingsPanel', () => ({
VMwareSettingsPanel: () => <div data-testid="vmware-section">vmware</div>,
vi.mock('../ConnectionEditor/CredentialSlots/VMwareCredentialSlot', () => ({
VMwareCredentialSlot: () => <div data-testid="vmware-section">vmware</div>,
}));
vi.mock('../InfrastructureActiveRowDetails', () => ({