From 4a215d40299e04d4cfec2a4a13f3a2dabd8c49a2 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:14:16 +0100 Subject: [PATCH] Load the bulk web-link panel on first open The editor pushed the WorkloadsSurface chunk to 71.44 kB gzip, past its 70.23 kB budget. Main already sat at 68.80 kB, so loading every row of a panel most sessions never open was the wrong cost to put on the table. The table-header trigger now keeps only the open flag and unsaved drafts and lazy-loads the panel, whose state and row model live in a separate 3.9 kB chunk. The table's saved-link lookup moves to its own small module so the table does not pull the editor's model back in. WorkloadsSurface builds at 69.13 kB. Drafts still survive closing the panel, and typing a link back to its saved value no longer counts as an unsaved change. --- .../subsystems/performance-and-scalability.md | 20 +- .../04ae6f445fa38e25.json | 48 ++ .../components/Workloads/WorkloadPanel.tsx | 2 +- .../Workloads/WorkloadWebLinksAction.tsx | 59 +++ .../Workloads/WorkloadWebLinksDialog.tsx | 427 +++++++----------- .../components/Workloads/WorkloadsSurface.tsx | 2 +- .../__tests__/WorkloadWebLinksDialog.test.tsx | 31 +- ...loadsSurface.performance.contract.test.tsx | 18 +- .../__tests__/workloadWebLinksModel.test.ts | 2 +- .../Workloads/useWorkloadWebLinksState.ts | 135 ++++++ .../Workloads/workloadGuestMetadataRecord.ts | 21 + .../Workloads/workloadWebLinksModel.ts | 29 +- 12 files changed, 470 insertions(+), 324 deletions(-) create mode 100644 frontend-modern/browser-verification/04ae6f445fa38e25.json create mode 100644 frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx create mode 100644 frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts create mode 100644 frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index bf3d2d6e9b..a6bbfb099d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1118,15 +1118,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. renders only for a saved URL and adds no fetch, polling or per-row work. `GuestDrawer.test.tsx` verifies its target, the unsafe-URL warning, and that opening it does not collapse the drawer. - The workloads table header owns the bulk web-link editor in - `WorkloadWebLinksDialog.tsx`, with derivations in - `workloadWebLinksModel.ts`. It edits the guests in the current filtered - view through the same guest metadata API and metadata-changed event as the - drawer field, and `getWorkloadGuestMetadataRecord` is the single lookup for - a row's saved link in both the table and the editor. Editor rows read live - inventory only while the dialog is open and keep unchanged row objects - across ticks, so a closed editor adds no per-tick work and an open one - never remounts the input being typed in. + The workloads table header owns the bulk web-link editor. The trigger in + `WorkloadWebLinksAction.tsx` holds only the open flag and unsaved drafts + and lazy-loads `WorkloadWebLinksDialog.tsx`, whose state + (`useWorkloadWebLinksState.ts`) and derivations + (`workloadWebLinksModel.ts`) stay out of the WorkloadsSurface chunk and + run only while the panel is mounted. It edits the guests in the current + filtered view through the same guest metadata API and metadata-changed + event as the drawer field. `workloadGuestMetadataRecord.ts` is the single + lookup for a row's saved link in both the table and the editor. Panel rows + keep unchanged row objects across inventory ticks so an open editor never + remounts the input being typed in. `WorkloadsSurface.performance.contract.test.tsx` pins that wiring. Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. A current metric may remain visible in a chart legend only when labelled diff --git a/frontend-modern/browser-verification/04ae6f445fa38e25.json b/frontend-modern/browser-verification/04ae6f445fa38e25.json new file mode 100644 index 0000000000..6e40bd6347 --- /dev/null +++ b/frontend-modern/browser-verification/04ae6f445fa38e25.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "base_sha": "967e51701e215cba3522dbafa4b1b6ac8620d333", + "verified_at": "2026-10-05T00:14:02Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", + "frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts", + "frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "72db08228690ba571a285771ef81b2ef9be308794da2ff1cd09be612efbbd29b", + "frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx": "9b57f62cf0de2196918f8faf9791e227fabaf960ad2ec3aa4302325379955e30", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "e779b8210c85326fda8bc37f7213384136766f93c80f06b6ac43eb8d0340f93c", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "42169c016a13fceb359fbd72b3284101769c27519aa749deb9c7a9fc72c12221", + "frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts": "8affd5f0798fa9c4af05cf1c763d1c3176200756a95e54294553f3b09969ac27", + "frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts": "532af19b043255ebcc116faa3b2247ca828c552f2f00ad544e06a62524761099", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "bab44fddeb88d842c60eddcbef834dcddc602e37a9e92af2a773ff3b3cca071a" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Follow-up to the bulk web-link editor: the CI bundle budget failed with WorkloadsSurface at 71.44 kB gzip against a 70.23 kB max (main was 68.80 kB). After moving the panel, its state and row model into a lazily loaded chunk, a local production build measured WorkloadsSurface at 69.13 kB and the new WorkloadWebLinksDialog chunk at 3.9 kB gzip; check-bundle-size reported no violations.", + "Private mock stack from this tree (backend :7701, Vite :5221, 31 Proxmox guests): Edit links opened the lazily loaded panel with all 31 rows and '7 of 31 in this view have a link', focus on the close button.", + "A draft typed into grafana-agent-01 survived Escape closing the panel; focus returned to the trigger, which showed (unsaved); reopening restored the draft; editing checkout-web-01 away and back to its saved value did not count as a change (footer stayed at 1 unsaved change).", + "Submitting saved grafana-agent-01 through PUT /api/guests/metadata, closed the panel, cleared the (unsaved) marker and the table row gained its link without reload.", + "Phone 375x812: full-width sheet, 44px inputs, no horizontal overflow; an invalid entry ('redis admin') showed the shared validator's message under its row with aria-invalid and focus. This is not physical-device, vSphere-estate or release acceptance." + ], + "interactions": [ + "Clicked Edit links at 1280 and 375 widths; typed into inputs across live inventory ticks; pressed Escape; reopened; submitted the form.", + "Submitted an invalid link on phone width; the shared validator's error appeared under that row, aria-invalid was set and focus moved to the field." + ] +} diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index 971c1b401f..9caf56f762 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -32,7 +32,7 @@ import { GuestDrawer } from './GuestDrawer'; import { GuestRow } from './GuestRow'; import { NodeDrawer } from './NodeDrawer'; import { buildWorkloadSummaryGroupScope } from './workloadSelectors'; -import { getWorkloadGuestMetadataRecord } from './workloadWebLinksModel'; +import { getWorkloadGuestMetadataRecord } from './workloadGuestMetadataRecord'; import type { WorkloadsState } from './useWorkloadsState'; import type { WorkloadTableMetric } from './workloadMetricHistoryModel'; diff --git a/frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx b/frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx new file mode 100644 index 0000000000..aac6b56333 --- /dev/null +++ b/frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx @@ -0,0 +1,59 @@ +import { Show, Suspense, createSignal, lazy, type Component } from 'solid-js'; +import Link2Icon from 'lucide-solid/icons/link-2'; +import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader'; +import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy'; +import type { WorkloadGuest } from '@/types/workloads'; +import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord'; +import type { WorkloadWebLinkDrafts } from './workloadWebLinksModel'; + +// The panel and its row model load on first open, so they stay out of the +// WorkloadsSurface chunk and do no work while the editor is closed. +const WorkloadWebLinksDialog = lazy(() => import('./WorkloadWebLinksDialog')); + +export interface WorkloadWebLinksActionProps { + guests: () => WorkloadGuest[]; + guestMetadata: () => WorkloadGuestMetadataMap; +} + +/** Table-header entry point for setting many guest web links at once. */ +export const WorkloadWebLinksAction: Component = (props) => { + const [open, setOpen] = createSignal(false); + // Drafts outlive the panel so closing it by accident keeps typed links. + const [drafts, setDrafts] = createSignal({}); + let triggerRef: HTMLButtonElement | undefined; + const preload = () => void WorkloadWebLinksDialog.preload(); + + return ( + + + + + setOpen(false)} + returnFocus={() => triggerRef} + /> + + + + ); +}; + +export default WorkloadWebLinksAction; diff --git a/frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx b/frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx index 3b58606e30..1ac368bc00 100644 --- a/frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx @@ -1,294 +1,183 @@ -import { For, Show, createMemo, createSignal, createUniqueId, type Component } from 'solid-js'; -import Link2Icon from 'lucide-solid/icons/link-2'; +import { For, Show, type Component } from 'solid-js'; import XIcon from 'lucide-solid/icons/x'; -import { GuestMetadataAPI } from '@/api/guestMetadata'; import { ActionIconButton, Button } from '@/components/shared/Button'; import { Dialog } from '@/components/shared/Dialog'; import { FilterSegmentedControl } from '@/components/shared/FilterToolbar'; import { formControlDense } from '@/components/shared/Form'; import { StatusDot } from '@/components/shared/StatusDot'; -import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader'; -import { notificationStore } from '@/stores/notifications'; -import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy'; -import type { WorkloadGuest } from '@/types/workloads'; -import { dispatchResourceMetadataChanged } from '@/utils/resourceMetadataEvents'; import { getSimpleStatusIndicator } from '@/utils/status'; import { - buildWorkloadWebLinkRows, - filterWorkloadWebLinkRows, - formatWorkloadWebLinkCount, - getWorkloadWebLinkChanges, + useWorkloadWebLinksState, + type WorkloadWebLinksStateProps, +} from './useWorkloadWebLinksState'; +import { getWorkloadWebLinkPlaceholder, getWorkloadWebLinkValue, - keepStableWorkloadWebLinkRows, - validateWorkloadWebLinkChanges, - type WorkloadGuestMetadataMap, - type WorkloadWebLinkDrafts, type WorkloadWebLinkFilter, - type WorkloadWebLinkRow, } from './workloadWebLinksModel'; -export interface WorkloadWebLinksActionProps { - guests: () => WorkloadGuest[]; - guestMetadata: () => WorkloadGuestMetadataMap; +export interface WorkloadWebLinksDialogProps extends WorkloadWebLinksStateProps { + returnFocus: () => HTMLElement | undefined; } -/** - * Table-header entry point for setting many web links at once. Drafts live - * here rather than in the dialog so closing it by accident keeps typed links. - */ -export const WorkloadWebLinksAction: Component = (props) => { - const formId = `workload-web-links-${createUniqueId()}`; - const [open, setOpen] = createSignal(false); - const [filter, setFilter] = createSignal('all'); - const [drafts, setDrafts] = createSignal({}); - const [errors, setErrors] = createSignal>({}); - const [saving, setSaving] = createSignal(false); - let triggerRef: HTMLButtonElement | undefined; - - // Rows only track live inventory while the dialog is open, so a closed - // editor adds no per-tick work to the workloads table. - const rows = createMemo( - (previous) => - open() - ? keepStableWorkloadWebLinkRows( - previous, - buildWorkloadWebLinkRows(props.guests(), props.guestMetadata()), - ) - : previous, - [], - ); - const visibleRows = createMemo(() => filterWorkloadWebLinkRows(rows(), filter())); - const linkedCount = createMemo(() => rows().filter((row) => row.savedUrl).length); - const changes = createMemo(() => getWorkloadWebLinkChanges(rows(), drafts())); - - const setDraft = (metadataId: string, value: string) => { - setDrafts((current) => ({ ...current, [metadataId]: value })); - if (errors()[metadataId]) { - setErrors(({ [metadataId]: _cleared, ...rest }) => rest); - } - }; - - const discardChanges = () => { - setDrafts({}); - setErrors({}); - }; - - const focusFirstError = (failed: Record) => { - const firstId = rows().find((row) => failed[row.metadataId])?.metadataId; - if (!firstId) return; - queueMicrotask(() => - Array.from(document.querySelectorAll(`#${formId} input`)) - .find((input) => input.dataset.metadataId === firstId) - ?.focus(), - ); - }; - - const save = async () => { - const pending = changes(); - if (pending.length === 0 || saving()) return; - - const invalid = validateWorkloadWebLinkChanges(pending); - if (Object.keys(invalid).length > 0) { - setErrors(invalid); - setFilter('all'); - focusFirstError(invalid); - return; - } - - setSaving(true); - const failed: Record = {}; - let savedCount = 0; - for (const change of pending) { - try { - await GuestMetadataAPI.updateMetadata(change.metadataId, { customUrl: change.url }); - dispatchResourceMetadataChanged({ - metadataKind: 'guest', - metadataId: change.metadataId, - customUrl: change.url, - }); - setDrafts(({ [change.metadataId]: _saved, ...rest }) => rest); - savedCount += 1; - } catch (error) { - failed[change.metadataId] = - error instanceof Error && error.message ? error.message : 'Could not save this link.'; - } - } - setSaving(false); - setErrors(failed); - - if (Object.keys(failed).length === 0) { - notificationStore.success(`Saved ${formatWorkloadWebLinkCount(savedCount)}.`); - setOpen(false); - return; - } - focusFirstError(failed); - }; +/** Bulk web-link panel, loaded on first open by WorkloadWebLinksAction. */ +export const WorkloadWebLinksDialog: Component = (props) => { + const state = useWorkloadWebLinksState(props); return ( - - - setOpen(false)} - layout="drawer-right" - panelClass="max-w-full sm:max-w-2xl" - ariaLabelledBy={`${formId}-title`} - returnFocus={() => triggerRef} - > -
-
-
-

- Web links -

-

- Add the address you open in your browser for each guest. Pulse shows it as a link - beside the guest. -

-
- setOpen(false)} - label="Close web links" - title="Close" - tone="muted" - size="md" - > - -
- -
-

- {linkedCount()} of {rows().length} in this view have a link +

+
+
+
+

+ Web links +

+

+ Add the address you open in your browser for each guest. Pulse shows it as a link + beside the guest.

- setFilter(value as WorkloadWebLinkFilter)} - options={[ - { value: 'all', label: 'All' }, - { value: 'missing', label: 'Without a link' }, - ]} - />
- -
{ - event.preventDefault(); - void save(); - }} + - 0} - fallback={ -

- {rows().length === 0 - ? 'No guests in this view.' - : 'Every guest in this view has a link.'} -

- } - > -
    - - {(row) => { - const inputId = `${formId}-${row.metadataId.replace(/[^a-zA-Z0-9_-]/g, '_')}`; - const errorId = `${inputId}-error`; - const status = () => getSimpleStatusIndicator(row.status); - const error = () => errors()[row.metadataId]; - return ( -
  • -
    - - -

    {row.detail}

    -
    -
    -
    - setDraft(row.metadataId, event.currentTarget.value)} - disabled={saving()} - aria-invalid={error() ? 'true' : undefined} - aria-describedby={error() ? errorId : undefined} - /> - -

    - {error()} -

    -
    -
    -
  • - ); - }} -
    -
-
- +
+
-
-

- {changes().length === 0 - ? 'No unsaved changes' - : `${changes().length} unsaved ${changes().length === 1 ? 'change' : 'changes'}`} -

-
- 0}> - - - -
-
+
+

+ {state.linkedCount()} of {state.rows().length} in this view have a link +

+ state.setFilter(value as WorkloadWebLinkFilter)} + options={[ + { value: 'all', label: 'All' }, + { value: 'missing', label: 'Without a link' }, + ]} + />
-
- + +
{ + event.preventDefault(); + void state.save(); + }} + > + 0} + fallback={ +

+ {state.rows().length === 0 + ? 'No guests in this view.' + : 'Every guest in this view has a link.'} +

+ } + > +
    + + {(row) => { + const inputId = `${state.formId}-${row.metadataId.replace(/[^a-zA-Z0-9_-]/g, '_')}`; + const errorId = `${inputId}-error`; + const status = () => getSimpleStatusIndicator(row.status); + const error = () => state.errors()[row.metadataId]; + return ( +
  • +
    + + +

    {row.detail}

    +
    +
    +
    + state.setDraft(row, event.currentTarget.value)} + disabled={state.saving()} + aria-invalid={error() ? 'true' : undefined} + aria-describedby={error() ? errorId : undefined} + /> + +

    + {error()} +

    +
    +
    +
  • + ); + }} +
    +
+
+
+ +
+

+ {state.changes().length === 0 + ? 'No unsaved changes' + : `${state.changes().length} unsaved ${state.changes().length === 1 ? 'change' : 'changes'}`} +

+
+ 0}> + + + +
+
+
+
); }; -export default WorkloadWebLinksAction; +export default WorkloadWebLinksDialog; diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index 456d239fee..fac247e183 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -16,7 +16,7 @@ import { hasActiveWorkloadsFilters, } from './workloadsFilterModel'; import { WorkloadsTable } from './WorkloadsTable'; -import { WorkloadWebLinksAction } from './WorkloadWebLinksDialog'; +import { WorkloadWebLinksAction } from './WorkloadWebLinksAction'; import type { WorkloadInventorySourceIssue } from './workloadInventorySourceIssues'; import { useWorkloadsState, diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadWebLinksDialog.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadWebLinksDialog.test.tsx index 727ea6e2d9..625f1945b1 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadWebLinksDialog.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadWebLinksDialog.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; import { createSignal } from 'solid-js'; import type { WorkloadGuest } from '@/types/workloads'; @@ -7,8 +7,8 @@ import { RESOURCE_METADATA_CHANGED_EVENT, type ResourceMetadataChangedDetail, } from '@/utils/resourceMetadataEvents'; -import { WorkloadWebLinksAction } from '../WorkloadWebLinksDialog'; -import type { WorkloadGuestMetadataMap } from '../workloadWebLinksModel'; +import { WorkloadWebLinksAction } from '../WorkloadWebLinksAction'; +import type { WorkloadGuestMetadataMap } from '../workloadGuestMetadataRecord'; const { updateMetadataMock, successMock, readOnlyMock } = vi.hoisted(() => ({ updateMetadataMock: vi.fn(), @@ -64,14 +64,19 @@ const renderAction = (metadata: WorkloadGuestMetadataMap = {}) => { }; }; -const openEditor = () => { +const openEditor = async () => { fireEvent.click(screen.getByRole('button', { name: /Edit links/ })); - return within(screen.getByRole('dialog', { name: 'Web links' })); + return within(await screen.findByRole('dialog', { name: 'Web links' })); }; describe('WorkloadWebLinksAction', () => { let harness: ReturnType | undefined; + // The panel is lazy-loaded; transform it once so each open resolves promptly. + beforeAll(async () => { + await import('../WorkloadWebLinksDialog'); + }, 30_000); + beforeEach(() => { updateMetadataMock.mockReset(); updateMetadataMock.mockResolvedValue({}); @@ -92,9 +97,9 @@ describe('WorkloadWebLinksAction', () => { expect(screen.queryByRole('button', { name: /Edit links/ })).not.toBeInTheDocument(); }); - it('lists every guest in the view with its saved link and a labelled input', () => { + it('lists every guest in the view with its saved link and a labelled input', async () => { harness = renderAction({ [authId]: { id: authId, customUrl: 'https://auth.example' } }); - const dialog = openEditor(); + const dialog = await openEditor(); expect(dialog.getByTestId('workload-web-links-summary')).toHaveTextContent( '1 of 3 in this view have a link', @@ -109,7 +114,7 @@ describe('WorkloadWebLinksAction', () => { it('blocks the whole save on an invalid link and explains it on that row', async () => { harness = renderAction(); - const dialog = openEditor(); + const dialog = await openEditor(); fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), { target: { value: 'https://cache.example' }, @@ -127,7 +132,7 @@ describe('WorkloadWebLinksAction', () => { it('saves additions and removals, notifies the table, and closes', async () => { harness = renderAction({ [authId]: { id: authId, customUrl: 'https://auth.example' } }); - const dialog = openEditor(); + const dialog = await openEditor(); fireEvent.input(dialog.getByLabelText(/auth-service-01/), { target: { value: '' } }); fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), { @@ -155,7 +160,7 @@ describe('WorkloadWebLinksAction', () => { return {}; }); harness = renderAction(); - const dialog = openEditor(); + const dialog = await openEditor(); fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), { target: { value: 'https://cache.example' }, @@ -176,9 +181,9 @@ describe('WorkloadWebLinksAction', () => { expect(dialog.getByRole('button', { name: 'Save' })).toBeEnabled(); }); - it('keeps unsaved drafts when the editor is closed and flags them on the trigger', () => { + it('keeps unsaved drafts when the editor is closed and flags them on the trigger', async () => { harness = renderAction(); - const dialog = openEditor(); + const dialog = await openEditor(); fireEvent.input(dialog.getByLabelText(/artifact-cache-01/), { target: { value: 'https://cache.example' }, @@ -188,7 +193,7 @@ describe('WorkloadWebLinksAction', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /Edit links/ })).toHaveTextContent('(unsaved)'); - const reopened = openEditor(); + const reopened = await openEditor(); expect(reopened.getByLabelText(/artifact-cache-01/)).toHaveValue('https://cache.example'); fireEvent.click(reopened.getByRole('button', { name: 'Discard' })); expect(reopened.getByLabelText(/artifact-cache-01/)).toHaveValue(''); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 653bcf4fe9..4c4c725c74 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -10,7 +10,8 @@ import workloadsFilterSource from '../WorkloadsFilter.tsx?raw'; import workloadsWorkloadTableSource from '../WorkloadsTable.tsx?raw'; import metricDisplayModeSegmentedControlSource from '../MetricDisplayModeSegmentedControl.tsx?raw'; import workloadPanelSource from '../WorkloadPanel.tsx?raw'; -import workloadWebLinksDialogSource from '../WorkloadWebLinksDialog.tsx?raw'; +import workloadWebLinksActionSource from '../WorkloadWebLinksAction.tsx?raw'; +import workloadWebLinksStateSource from '../useWorkloadWebLinksState.ts?raw'; import workloadTableHeaderSource from '../WorkloadTableHeader.tsx?raw'; import workloadsFilterModelSource from '../workloadsFilterModel.ts?raw'; import workloadsControlsStateSource from '../useWorkloadsControlsState.ts?raw'; @@ -1402,12 +1403,17 @@ describe('Workloads performance contract', () => { expect(workloadPanelSource).toContain( 'getWorkloadGuestMetadataRecord(guest(), props.guestMetadata())', ); - // The bulk link editor must not rebuild rows on inventory ticks while closed. - expect(workloadWebLinksDialogSource).toContain('keepStableWorkloadWebLinkRows('); - expect(workloadWebLinksDialogSource).toMatch( - /open\(\)\s*\?\s*keepStableWorkloadWebLinkRows\(/, + // The bulk link panel and its row model load on first open, outside the + // WorkloadsSurface chunk, and only mount while the editor is open. + expect(workloadWebLinksActionSource).toContain( + "lazy(() => import('./WorkloadWebLinksDialog'))", ); - expect(workloadWebLinksDialogSource).toContain(': previous,'); + expect(workloadWebLinksActionSource).toContain(''); + // Type-only imports are erased, so the row model stays in the lazy chunk. + expect(workloadWebLinksActionSource).not.toMatch( + /^import (?!type )[^;]*from '\.\/(workloadWebLinksModel|useWorkloadWebLinksState)';/m, + ); + expect(workloadWebLinksStateSource).toContain('keepStableWorkloadWebLinkRows('); expect(workloadsSource).toContain(''); expect(workloadsSource).toContain('title={props.tableTitle}'); expect(workloadsWorkloadTableSource).toContain( diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadWebLinksModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadWebLinksModel.test.ts index 11a19ac9a2..8a675a91ca 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadWebLinksModel.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadWebLinksModel.test.ts @@ -4,12 +4,12 @@ import { getWorkloadMetadataId } from '@/utils/workloads'; import { buildWorkloadWebLinkRows, filterWorkloadWebLinkRows, - getWorkloadGuestMetadataRecord, getWorkloadWebLinkChanges, getWorkloadWebLinkPlaceholder, keepStableWorkloadWebLinkRows, validateWorkloadWebLinkChanges, } from '../workloadWebLinksModel'; +import { getWorkloadGuestMetadataRecord } from '../workloadGuestMetadataRecord'; const makeGuest = (overrides: Partial = {}): WorkloadGuest => ({ diff --git a/frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts b/frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts new file mode 100644 index 0000000000..7f7741afaf --- /dev/null +++ b/frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts @@ -0,0 +1,135 @@ +import { createMemo, createSignal, createUniqueId, type Setter } from 'solid-js'; +import { GuestMetadataAPI } from '@/api/guestMetadata'; +import { notificationStore } from '@/stores/notifications'; +import type { WorkloadGuest } from '@/types/workloads'; +import { dispatchResourceMetadataChanged } from '@/utils/resourceMetadataEvents'; +import type { WorkloadGuestMetadataMap } from './workloadGuestMetadataRecord'; +import { + buildWorkloadWebLinkRows, + filterWorkloadWebLinkRows, + formatWorkloadWebLinkCount, + getWorkloadWebLinkChanges, + keepStableWorkloadWebLinkRows, + validateWorkloadWebLinkChanges, + type WorkloadWebLinkDrafts, + type WorkloadWebLinkFilter, + type WorkloadWebLinkRow, +} from './workloadWebLinksModel'; + +export interface WorkloadWebLinksStateProps { + guests: () => WorkloadGuest[]; + guestMetadata: () => WorkloadGuestMetadataMap; + /** Owned by the table-header trigger so closing the panel keeps typed links. */ + drafts: () => WorkloadWebLinkDrafts; + setDrafts: Setter; + onClose: () => void; +} + +/** + * Bulk web-link panel state. The panel only mounts while open, so its live + * inventory reads add no per-tick work to a table whose editor is closed. + */ +export function useWorkloadWebLinksState(props: WorkloadWebLinksStateProps) { + const formId = `workload-web-links-${createUniqueId()}`; + const [filter, setFilter] = createSignal('all'); + const [errors, setErrors] = createSignal>({}); + const [saving, setSaving] = createSignal(false); + + const rows = createMemo( + (previous) => + keepStableWorkloadWebLinkRows( + previous, + buildWorkloadWebLinkRows(props.guests(), props.guestMetadata()), + ), + [], + ); + const visibleRows = createMemo(() => filterWorkloadWebLinkRows(rows(), filter())); + const linkedCount = createMemo(() => rows().filter((row) => row.savedUrl).length); + const changes = createMemo(() => getWorkloadWebLinkChanges(rows(), props.drafts())); + + const setDraft = (row: WorkloadWebLinkRow, value: string) => { + // Typing a link back to its saved value drops the draft, so the trigger's + // unsaved marker only reflects real edits. + props.setDrafts(({ [row.metadataId]: _previous, ...rest }) => + value === row.savedUrl ? rest : { ...rest, [row.metadataId]: value }, + ); + if (errors()[row.metadataId]) { + setErrors(({ [row.metadataId]: _cleared, ...rest }) => rest); + } + }; + + const discardChanges = () => { + props.setDrafts({}); + setErrors({}); + }; + + const focusFirstError = (failed: Record) => { + const firstId = rows().find((row) => failed[row.metadataId])?.metadataId; + if (!firstId) return; + queueMicrotask(() => + Array.from(document.querySelectorAll(`#${formId} input`)) + .find((input) => input.dataset.metadataId === firstId) + ?.focus(), + ); + }; + + const save = async () => { + const pending = changes(); + if (pending.length === 0 || saving()) return; + + const invalid = validateWorkloadWebLinkChanges(pending); + if (Object.keys(invalid).length > 0) { + setErrors(invalid); + setFilter('all'); + focusFirstError(invalid); + return; + } + + setSaving(true); + const failed: Record = {}; + let savedCount = 0; + for (const change of pending) { + try { + await GuestMetadataAPI.updateMetadata(change.metadataId, { customUrl: change.url }); + dispatchResourceMetadataChanged({ + metadataKind: 'guest', + metadataId: change.metadataId, + customUrl: change.url, + }); + props.setDrafts(({ [change.metadataId]: _saved, ...rest }) => rest); + savedCount += 1; + } catch (error) { + failed[change.metadataId] = + error instanceof Error && error.message ? error.message : 'Could not save this link.'; + } + } + setSaving(false); + setErrors(failed); + + if (Object.keys(failed).length === 0) { + notificationStore.success(`Saved ${formatWorkloadWebLinkCount(savedCount)}.`); + props.onClose(); + return; + } + focusFirstError(failed); + }; + + return { + formId, + filter, + setFilter, + drafts: props.drafts, + setDraft, + discardChanges, + errors, + saving, + rows, + visibleRows, + linkedCount, + changes, + save, + close: props.onClose, + }; +} + +export type WorkloadWebLinksState = ReturnType; diff --git a/frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts b/frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts new file mode 100644 index 0000000000..180ca77063 --- /dev/null +++ b/frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts @@ -0,0 +1,21 @@ +import type { GuestMetadata } from '@/api/guestMetadata'; +import type { WorkloadGuest } from '@/types/workloads'; +import { getWorkloadMetadataIdCandidates } from '@/utils/workloads'; + +export type WorkloadGuestMetadataMap = Record; + +/** + * Saved metadata for a workload row. Stable identities win over the legacy + * instance:node:vmid key that v5 data volumes still carry. + */ +export const getWorkloadGuestMetadataRecord = ( + guest: WorkloadGuest, + byId: WorkloadGuestMetadataMap, +): GuestMetadata | undefined => { + for (const metadataId of getWorkloadMetadataIdCandidates(guest)) { + if (metadataId && byId[metadataId]) { + return byId[metadataId]; + } + } + return byId[`${guest.instance}:${guest.node}:${guest.vmid}`]; +}; diff --git a/frontend-modern/src/components/Workloads/workloadWebLinksModel.ts b/frontend-modern/src/components/Workloads/workloadWebLinksModel.ts index 4d48792b00..b2829a62f4 100644 --- a/frontend-modern/src/components/Workloads/workloadWebLinksModel.ts +++ b/frontend-modern/src/components/Workloads/workloadWebLinksModel.ts @@ -1,34 +1,15 @@ -import type { GuestMetadata } from '@/api/guestMetadata'; import { normalizeWebInterfaceUrl, validateWebInterfaceCustomUrl, } from '@/components/shared/webInterfaceUrlFieldModel'; import type { WorkloadGuest } from '@/types/workloads'; import { getWorkloadTypeLabel } from '@/utils/workloadTypePresentation'; -import { - getWorkloadMetadataId, - getWorkloadMetadataIdCandidates, - resolveWorkloadType, -} from '@/utils/workloads'; +import { getWorkloadMetadataId, resolveWorkloadType } from '@/utils/workloads'; import { getWorkloadDisplayId } from './guestRowModel'; - -export type WorkloadGuestMetadataMap = Record; - -/** - * Saved metadata for a workload row. Stable identities win over the legacy - * instance:node:vmid key that v5 data volumes still carry. - */ -export const getWorkloadGuestMetadataRecord = ( - guest: WorkloadGuest, - byId: WorkloadGuestMetadataMap, -): GuestMetadata | undefined => { - for (const metadataId of getWorkloadMetadataIdCandidates(guest)) { - if (metadataId && byId[metadataId]) { - return byId[metadataId]; - } - } - return byId[`${guest.instance}:${guest.node}:${guest.vmid}`]; -}; +import { + getWorkloadGuestMetadataRecord, + type WorkloadGuestMetadataMap, +} from './workloadGuestMetadataRecord'; export type WorkloadWebLinkFilter = 'all' | 'missing';