Keep infrastructure edits mounted across polls

Connection-ledger refreshes replaced the selected connection snapshot and re-ran the credential slot renderer, destroying unsaved form state despite the node-level dirty guard. Keep the editor owner bound to the selected source while allowing the surrounding live row and dialog description to refresh.

Regression coverage advances both lastSeen and the endpoint, proving the form mounts once and retains input while live context updates.

Contract-Neutral: frontend state-lifetime bugfix; no API or persisted-data change.
This commit is contained in:
pulse-triage[bot]
2026-09-03 20:49:29 +01:00
parent 54d58a7406
commit 93c765a7ec
3 changed files with 90 additions and 45 deletions
+17 -35
View File
@@ -1,32 +1,16 @@
{
"version": 1,
"base_sha": "ef460aa6542a41728e2fe3e4a8266488c919b0be",
"verified_at": "2026-09-02T18:04:36Z",
"base_sha": "54d58a74062ffb15c14510a24223f21fa6ec2fd3",
"verified_at": "2026-09-03T20:15:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/Settings.tsx",
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx",
"frontend-modern/src/components/WhatsNewCard.tsx",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/utils/localStorage.ts"
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "9512529c0e6b86ba4e1055c5009158d442bd8d4a84c2dd9f73822a053989886a",
"frontend-modern/src/components/Settings/Settings.tsx": "72682c86dd055761cbd68d4f57b684afe4d978a0b53748859d0721e711ce321a",
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx": "7f8ddc0c826b5abe90f40ac08ef3856d046510a7f72afe2f40f32ae72e547eba",
"frontend-modern/src/components/WhatsNewCard.tsx": "90543f19d61e9ba6d93251a7bcc9c179fa8d7ccb6ab3364e364d0ea13b2c31f0",
"frontend-modern/src/i18n/messages.de.ts": "f43d3c32d59f32178531e11a56e6014d384831871bb8cc3b472fca8e20a96840",
"frontend-modern/src/i18n/messages.es.ts": "928c7c7b34dd75ffd00639cb8b27ad81c0e6c8210db8ff41c234a26c5b9c9031",
"frontend-modern/src/i18n/messages.ts": "fef88e4096b8d49dc8e4a5b721804bb3b602dddef442827f0b315bec0d45a435",
"frontend-modern/src/utils/localStorage.ts": "19a532f55271b2fc696e3ba4ae6ba098e759f0f9df207015c803f7fcc0050bd6"
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx": "186324f5ede149574cebe2d758b4984077c9105c5ae926721e262cc26a5e2502"
},
"routes": [
"/ (first-run setup wizard, fresh data dir)",
"/settings/system-general#usage-telemetry (mock data, existing-install localStorage baseline)",
"/docs/PRIVACY (shipped privacy document)"
"/settings/infrastructure (managed local backend with deterministic Proxmox connection-ledger and node fixtures)"
],
"viewports": [
{
@@ -39,21 +23,19 @@
}
],
"states": [
"Setup welcome step: Usage statistics card, purpose-first copy, never-sold statement, \"you choose on the next step\", no PULSE_TELEMETRY instruction",
"Setup security step (bootstrap validation and quick-setup responses intercepted by Playwright, no real token): Usage statistics toggle default on, description leads with the benefit and carries the never-sold statement",
"Setup security step: toggle off, aria-pressed false; completion reached after Create Account",
"Settings system-general with prior release baseline: no telemetry-payload-update-notice banner rendered",
"Settings Usage data and privacy panel: description leads with purpose, carries the never-sold statement, Preview payload rendered as primary Button, Reset ID secondary",
"Settings panel with payload preview open at 390px: no horizontal page overflow",
"Shipped /docs/PRIVACY renders the \"What it is not used for\" section and the Payload changes table",
"Locales: en verified live; de/es catalog copy covered by i18n and SetupWizard localization unit tests"
"Manage Proxmox VE dialog with Username & Password selected and an unsaved username at desktop width",
"Desktop dialog after a 15-second connection-ledger poll replaced lastSeen and endpoint: live description updated, local endpoint and unsaved username remained unchanged",
"Manage Proxmox VE dialog with a distinct unsaved username at narrow width",
"Narrow dialog after a second 15-second ledger replacement: live description updated, local endpoint and unsaved username remained unchanged",
"Scrollable dialog body with visible header and close control, reachable Save changes footer, and no document-width overflow at both widths",
"Closed dialog with focus returned to the originating Manage button at both widths"
],
"interactions": [
"Fill bootstrap token field with a placeholder and click Verify bootstrap token (validation endpoint intercepted)",
"Click Usage statistics toggle on the security step",
"Click Create Account & Continue: observed POST /api/security/quick-setup then POST /api/system/settings/update {\"telemetryEnabled\":false}",
"Click Preview payload in Settings and read the rendered heartbeat JSON",
"Click Full details from the Settings panel and read the popup document",
"Resize to 390x844 and repeat the wizard, security step, and Settings panel checks"
"Opened Manage for a configured Proxmox VE source",
"Selected Username & Password and entered a non-secret unsaved username",
"Waited for the real 15-second ledger poll and observed endpoint/lastSeen replacement without form remount or value loss",
"Scrolled to and verified the Save changes footer remained reachable, then returned to the edited field",
"Pressed Escape and verified dialog dismissal and focus return to Manage",
"Repeated the complete edit, poll, scroll, dismissal, and focus-return journey at 390x844"
]
}
@@ -7,6 +7,7 @@ import {
createMemo,
createSignal,
onCleanup,
untrack,
} from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import X from 'lucide-solid/icons/x';
@@ -711,7 +712,14 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
onSaved: () => void;
}) => {
if (context.mode === 'edit') {
const connection = editingConnection();
// Connection-ledger polls replace otherwise identical connection
// snapshots (for example when lastSeen advances). Do not make the
// credential-slot owner depend on those snapshots: recreating the slot
// discards its local form state before the operator can save. The
// selected source identity remains owned by editingRowSource until this
// flow closes, while the live row and attached-agent details continue
// to update elsewhere in the dialog.
const connection = untrack(editingConnection);
if (!connection) {
return (
<div
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Connection } from '@/api/connections';
import type {
@@ -11,10 +12,27 @@ const routeState = vi.hoisted(() => ({
pathname: '/settings/infrastructure',
search: '',
}));
const connectionState = vi.hoisted(() => ({
connections: [] as Connection[],
rows: null as InfrastructureSystemRow[] | null,
}));
const connectionState = vi.hoisted(() => {
let connections: Connection[] = [];
let observe = () => {};
let notifyChanged = () => {};
return {
get connections() {
observe();
return connections;
},
set connections(value: Connection[]) {
connections = value;
},
rows: null as InfrastructureSystemRow[] | null,
setObserver(nextObserve: () => void, nextNotifyChanged: () => void) {
observe = nextObserve;
notifyChanged = nextNotifyChanged;
},
notifyChanged: () => notifyChanged(),
};
});
const nodeCredentialSlotState = vi.hoisted(() => ({ mountCount: 0 }));
const emptyFleetRow = vi.hoisted(
() =>
({
@@ -169,11 +187,14 @@ vi.mock('../InfrastructureInstallerSection', () => ({
}));
vi.mock('../ConnectionEditor/CredentialSlots/NodeCredentialSlot', () => ({
NodeCredentialSlot: (props: { nodeType: string }) => (
<div data-testid="proxmox-section" data-node-type={props.nodeType}>
proxmox
</div>
),
NodeCredentialSlot: (props: { nodeType: string }) => {
nodeCredentialSlotState.mountCount += 1;
return (
<div data-testid="proxmox-section" data-node-type={props.nodeType}>
<input aria-label="Authentication method" value="api" />
</div>
);
},
}));
vi.mock('../ConnectionEditor/CredentialSlots/TrueNASCredentialSlot', () => ({
@@ -287,6 +308,11 @@ describe('InfrastructureWorkspace', () => {
routeState.search = '';
connectionState.connections = [connectionFixture()];
connectionState.rows = null;
connectionState.setObserver(
() => {},
() => {},
);
nodeCredentialSlotState.mountCount = 0;
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
@@ -1002,6 +1028,35 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(manageButton).toHaveFocus());
});
it('keeps the mounted node editor across connection-ledger refreshes', async () => {
const [revision, setRevision] = createSignal(0);
connectionState.setObserver(revision, () => setRevision((value) => value + 1));
renderWorkspace({
pveNodes: () => [{ id: 'pve-0', type: 'pve', name: 'zeus' } as any],
});
fireEvent.click(await screen.findByRole('button', { name: /^Manage$/i }));
const method = await screen.findByRole('textbox', { name: 'Authentication method' });
fireEvent.input(method, { target: { value: 'agent' } });
expect(method).toHaveValue('agent');
connectionState.connections = [
connectionFixture({
address: 'https://10.0.0.2:8006',
lastSeen: new Date(Date.now() + 15_000).toISOString(),
}),
];
connectionState.notifyChanged();
await waitFor(() =>
expect(screen.getByRole('textbox', { name: 'Authentication method' })).toHaveValue('agent'),
);
expect(screen.getByRole('dialog')).toHaveAccessibleDescription(
'Proxmox VE · https://10.0.0.2:8006',
);
expect(nodeCredentialSlotState.mountCount).toBe(1);
});
it('shows standalone agent identity in the landing row and the agent detail drawer', async () => {
const towerAgent = connectionFixture({
id: 'agent:tower',