Route infrastructure workspace node edits to the update endpoint

The source-manager workspace retired the legacy per-type shells that set
the configured-nodes editingNode signal, so every save from the Manage
flow fell through to the add endpoint. Editing an existing PVE, PBS or
PMG connection then failed with "Authentication credentials required"
(or a duplicate-host conflict) and the editor closed anyway, discarding
the form. Pass the edited node along with the save call, report save
success, and keep the editor open when the save fails.

Fixes #1605

Contract-Neutral: #1605 frontend-only fix routing workspace node edits to the update endpoint; no subsystem boundary moved
This commit is contained in:
rcourtman
2026-07-22 23:17:47 +01:00
parent 8510243094
commit 24e7bf41c4
4 changed files with 89 additions and 6 deletions
@@ -52,8 +52,13 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
const [importImpactPreviewError, setImportImpactPreviewError] = createSignal<string | null>(null);
const handleSave = async (nodeData: Partial<NodeConfig>) => {
await props.settings.saveNode(nodeData);
props.onSaved();
// The configured-nodes state no longer tracks which node the workspace is
// editing, so the edit target must travel with the save call; otherwise
// edits are misrouted to the add endpoint (#1605).
const saved = await props.settings.saveNode(nodeData, props.editingNode ?? null);
if (saved) {
props.onSaved();
}
};
const modalProps: NodeModalProps = {
@@ -15,7 +15,7 @@ const createSettings = (): InfrastructurePlatformSettingsProps =>
savingTemperatureSetting: () => false,
handleNodeTemperatureMonitoringChange: vi.fn().mockResolvedValue(undefined),
handleTemperatureMonitoringChange: vi.fn().mockResolvedValue(undefined),
saveNode: vi.fn().mockResolvedValue(undefined),
saveNode: vi.fn().mockResolvedValue(true),
}) as unknown as InfrastructurePlatformSettingsProps;
describe('NodeCredentialSlot', () => {
@@ -185,4 +185,74 @@ describe('NodeCredentialSlot', () => {
const payload = vi.mocked(settings.saveNode).mock.calls[0][0];
expect(payload).not.toHaveProperty('clusterEndpointOverrides');
});
it('passes the edited node to saveNode so edits hit the update path', async () => {
const settings = createSettings();
const onSaved = vi.fn();
const editingNode = {
id: 'pve-0',
type: 'pve',
name: 'homelab',
host: 'https://pve1.local:8006',
user: '',
tokenName: 'root@pam!pulse',
hasToken: true,
verifySSL: true,
status: 'connected',
} as unknown as NodeConfigWithStatus;
render(() => (
<NodeCredentialSlot
nodeType="pve"
settings={settings}
editingNode={editingNode}
onCancel={vi.fn()}
onSaved={onSaved}
/>
));
fireEvent.submit(screen.getByRole('button', { name: 'Save changes' }).closest('form')!);
await vi.waitFor(() => {
expect(settings.saveNode).toHaveBeenCalledTimes(1);
});
expect(vi.mocked(settings.saveNode).mock.calls[0][1]).toBe(editingNode);
await vi.waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
});
});
it('keeps the editor open when the save fails', async () => {
const settings = createSettings();
vi.mocked(settings.saveNode).mockResolvedValue(false);
const onSaved = vi.fn();
const editingNode = {
id: 'pve-0',
type: 'pve',
name: 'homelab',
host: 'https://pve1.local:8006',
user: '',
tokenName: 'root@pam!pulse',
hasToken: true,
verifySSL: true,
status: 'connected',
} as unknown as NodeConfigWithStatus;
render(() => (
<NodeCredentialSlot
nodeType="pve"
settings={settings}
editingNode={editingNode}
onCancel={vi.fn()}
onSaved={onSaved}
/>
));
fireEvent.submit(screen.getByRole('button', { name: 'Save changes' }).closest('form')!);
await vi.waitFor(() => {
expect(settings.saveNode).toHaveBeenCalledTimes(1);
});
expect(onSaved).not.toHaveBeenCalled();
});
});
@@ -67,7 +67,10 @@ export interface InfrastructurePlatformSettingsProps {
savingDockerUpdateActions: Accessor<boolean>;
handleDisableDockerUpdateActionsChange: (enabled: boolean) => Promise<void>;
handleNodeTemperatureMonitoringChange: (nodeId: string, enabled: boolean | null) => Promise<void>;
saveNode: (nodeData: Partial<NodeConfig>) => Promise<void>;
saveNode: (
nodeData: Partial<NodeConfig>,
existingNode?: NodeConfigWithStatus | null,
) => Promise<boolean>;
showDeleteNodeModal: Accessor<boolean>;
cancelDeleteNode: () => void;
deleteNode: () => Promise<void>;
@@ -281,9 +281,12 @@ export const useInfrastructureConfiguredNodesState = ({
}
};
const saveNode = async (nodeData: Partial<NodeConfig>) => {
const saveNode = async (
nodeData: Partial<NodeConfig>,
existingNodeOverride?: NodeConfigWithStatus | null,
): Promise<boolean> => {
try {
const existingNode = editingNode();
const existingNode = existingNodeOverride ?? editingNode();
if (existingNode?.id) {
await NodesAPI.updateNode(existingNode.id, nodeData as NodeConfig);
// clusterEndpointOverrides is a write-only payload field: mirror it
@@ -322,8 +325,10 @@ export const useInfrastructureConfiguredNodesState = ({
setShowNodeModal(false);
setEditingNode(null);
return true;
} catch (error) {
notificationStore.error(error instanceof Error ? error.message : 'Operation failed');
return false;
}
};