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';