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.
This commit is contained in:
rcourtman
2026-10-05 01:14:16 +01:00
parent 967e51701e
commit 4a215d4029
12 changed files with 470 additions and 324 deletions
@@ -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
@@ -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."
]
}
@@ -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';
@@ -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<WorkloadWebLinksActionProps> = (props) => {
const [open, setOpen] = createSignal(false);
// Drafts outlive the panel so closing it by accident keeps typed links.
const [drafts, setDrafts] = createSignal<WorkloadWebLinkDrafts>({});
let triggerRef: HTMLButtonElement | undefined;
const preload = () => void WorkloadWebLinksDialog.preload();
return (
<Show when={!presentationPolicyIsReadOnly()}>
<button
ref={triggerRef}
type="button"
class={`${TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS} min-h-6 gap-1`}
aria-haspopup="dialog"
onPointerEnter={preload}
onFocus={preload}
onClick={() => setOpen(true)}
>
<Link2Icon class="h-3.5 w-3.5" aria-hidden="true" />
Edit links
<Show when={Object.keys(drafts()).length > 0}>
<span class="text-amber-600 dark:text-amber-400">(unsaved)</span>
</Show>
</button>
<Show when={open()}>
<Suspense fallback={null}>
<WorkloadWebLinksDialog
guests={props.guests}
guestMetadata={props.guestMetadata}
drafts={drafts}
setDrafts={setDrafts}
onClose={() => setOpen(false)}
returnFocus={() => triggerRef}
/>
</Suspense>
</Show>
</Show>
);
};
export default WorkloadWebLinksAction;
@@ -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<WorkloadWebLinksActionProps> = (props) => {
const formId = `workload-web-links-${createUniqueId()}`;
const [open, setOpen] = createSignal(false);
const [filter, setFilter] = createSignal<WorkloadWebLinkFilter>('all');
const [drafts, setDrafts] = createSignal<WorkloadWebLinkDrafts>({});
const [errors, setErrors] = createSignal<Record<string, string>>({});
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<WorkloadWebLinkRow[]>(
(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<string, string>) => {
const firstId = rows().find((row) => failed[row.metadataId])?.metadataId;
if (!firstId) return;
queueMicrotask(() =>
Array.from(document.querySelectorAll<HTMLInputElement>(`#${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<string, string> = {};
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<WorkloadWebLinksDialogProps> = (props) => {
const state = useWorkloadWebLinksState(props);
return (
<Show when={!presentationPolicyIsReadOnly()}>
<button
ref={triggerRef}
type="button"
class={`${TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS} min-h-6 gap-1`}
aria-haspopup="dialog"
onClick={() => setOpen(true)}
>
<Link2Icon class="h-3.5 w-3.5" aria-hidden="true" />
Edit links
<Show when={changes().length > 0}>
<span class="text-amber-600 dark:text-amber-400">(unsaved)</span>
</Show>
</button>
<Dialog
isOpen={open()}
onClose={() => setOpen(false)}
layout="drawer-right"
panelClass="max-w-full sm:max-w-2xl"
ariaLabelledBy={`${formId}-title`}
returnFocus={() => triggerRef}
>
<div class="flex h-full min-h-0 flex-col" data-testid="workload-web-links">
<header class="flex shrink-0 items-start justify-between gap-3 border-b border-border bg-surface px-4 py-3">
<div class="min-w-0">
<h2 id={`${formId}-title`} class="text-base font-semibold text-base-content">
Web links
</h2>
<p class="text-xs text-muted">
Add the address you open in your browser for each guest. Pulse shows it as a link
beside the guest.
</p>
</div>
<ActionIconButton
type="button"
onClick={() => setOpen(false)}
label="Close web links"
title="Close"
tone="muted"
size="md"
>
<XIcon class="h-5 w-5" aria-hidden="true" />
</ActionIconButton>
</header>
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
<p class="text-xs text-muted" data-testid="workload-web-links-summary">
{linkedCount()} of {rows().length} in this view have a link
<Dialog
isOpen
onClose={state.close}
layout="drawer-right"
panelClass="max-w-full sm:max-w-2xl"
ariaLabelledBy={`${state.formId}-title`}
returnFocus={props.returnFocus}
>
<div class="flex h-full min-h-0 flex-col" data-testid="workload-web-links">
<header class="flex shrink-0 items-start justify-between gap-3 border-b border-border bg-surface px-4 py-3">
<div class="min-w-0">
<h2 id={`${state.formId}-title`} class="text-base font-semibold text-base-content">
Web links
</h2>
<p class="text-xs text-muted">
Add the address you open in your browser for each guest. Pulse shows it as a link
beside the guest.
</p>
<FilterSegmentedControl
aria-label="Show guests"
value={filter()}
onChange={(value) => setFilter(value as WorkloadWebLinkFilter)}
options={[
{ value: 'all', label: 'All' },
{ value: 'missing', label: 'Without a link' },
]}
/>
</div>
<form
id={formId}
noValidate
class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
<ActionIconButton
type="button"
onClick={state.close}
label="Close web links"
title="Close"
tone="muted"
size="md"
>
<Show
when={visibleRows().length > 0}
fallback={
<p class="py-6 text-center text-sm text-muted">
{rows().length === 0
? 'No guests in this view.'
: 'Every guest in this view has a link.'}
</p>
}
>
<ul class="divide-y divide-border">
<For each={visibleRows()}>
{(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 (
<li class="flex flex-col gap-1.5 py-2.5 sm:flex-row sm:items-start sm:gap-3">
<div class="min-w-0 sm:w-52 sm:shrink-0 sm:pt-1.5">
<label
for={inputId}
class="flex items-center gap-1.5 text-sm font-medium text-base-content"
>
<StatusDot
variant={status().variant}
ariaLabel={status().label}
title={status().label}
size="xs"
/>
<span class="break-all">{row.name}</span>
</label>
<Show when={row.detail}>
<p class="pl-3 text-[11px] text-muted">{row.detail}</p>
</Show>
</div>
<div class="min-w-0 flex-1">
<input
id={inputId}
data-metadata-id={row.metadataId}
type="url"
inputMode="url"
autocomplete="off"
spellcheck={false}
class={formControlDense}
placeholder={getWorkloadWebLinkPlaceholder(row)}
value={getWorkloadWebLinkValue(row, drafts())}
onInput={(event) => setDraft(row.metadataId, event.currentTarget.value)}
disabled={saving()}
aria-invalid={error() ? 'true' : undefined}
aria-describedby={error() ? errorId : undefined}
/>
<Show when={error()}>
<p id={errorId} class="mt-1 text-xs text-red-600 dark:text-red-400">
{error()}
</p>
</Show>
</div>
</li>
);
}}
</For>
</ul>
</Show>
</form>
<XIcon class="h-5 w-5" aria-hidden="true" />
</ActionIconButton>
</header>
<footer class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3">
<p class="text-xs text-muted" role="status" aria-live="polite">
{changes().length === 0
? 'No unsaved changes'
: `${changes().length} unsaved ${changes().length === 1 ? 'change' : 'changes'}`}
</p>
<div class="flex items-center gap-2">
<Show when={changes().length > 0}>
<Button variant="ghost" size="sm" onClick={discardChanges} disabled={saving()}>
Discard
</Button>
</Show>
<Button
type="submit"
form={formId}
variant="primary"
size="sm"
disabled={changes().length === 0}
isLoading={saving()}
>
{changes().length > 1 ? `Save ${changes().length} links` : 'Save'}
</Button>
</div>
</footer>
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
<p class="text-xs text-muted" data-testid="workload-web-links-summary">
{state.linkedCount()} of {state.rows().length} in this view have a link
</p>
<FilterSegmentedControl
aria-label="Show guests"
value={state.filter()}
onChange={(value) => state.setFilter(value as WorkloadWebLinkFilter)}
options={[
{ value: 'all', label: 'All' },
{ value: 'missing', label: 'Without a link' },
]}
/>
</div>
</Dialog>
</Show>
<form
id={state.formId}
noValidate
class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4"
onSubmit={(event) => {
event.preventDefault();
void state.save();
}}
>
<Show
when={state.visibleRows().length > 0}
fallback={
<p class="py-6 text-center text-sm text-muted">
{state.rows().length === 0
? 'No guests in this view.'
: 'Every guest in this view has a link.'}
</p>
}
>
<ul class="divide-y divide-border">
<For each={state.visibleRows()}>
{(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 (
<li class="flex flex-col gap-1.5 py-2.5 sm:flex-row sm:items-start sm:gap-3">
<div class="min-w-0 sm:w-52 sm:shrink-0 sm:pt-1.5">
<label
for={inputId}
class="flex items-center gap-1.5 text-sm font-medium text-base-content"
>
<StatusDot
variant={status().variant}
ariaLabel={status().label}
title={status().label}
size="xs"
/>
<span class="break-all">{row.name}</span>
</label>
<Show when={row.detail}>
<p class="pl-3 text-[11px] text-muted">{row.detail}</p>
</Show>
</div>
<div class="min-w-0 flex-1">
<input
id={inputId}
data-metadata-id={row.metadataId}
type="url"
inputMode="url"
autocomplete="off"
spellcheck={false}
class={formControlDense}
placeholder={getWorkloadWebLinkPlaceholder(row)}
value={getWorkloadWebLinkValue(row, state.drafts())}
onInput={(event) => state.setDraft(row, event.currentTarget.value)}
disabled={state.saving()}
aria-invalid={error() ? 'true' : undefined}
aria-describedby={error() ? errorId : undefined}
/>
<Show when={error()}>
<p id={errorId} class="mt-1 text-xs text-red-600 dark:text-red-400">
{error()}
</p>
</Show>
</div>
</li>
);
}}
</For>
</ul>
</Show>
</form>
<footer class="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3">
<p class="text-xs text-muted" role="status" aria-live="polite">
{state.changes().length === 0
? 'No unsaved changes'
: `${state.changes().length} unsaved ${state.changes().length === 1 ? 'change' : 'changes'}`}
</p>
<div class="flex items-center gap-2">
<Show when={state.changes().length > 0}>
<Button
variant="ghost"
size="sm"
onClick={state.discardChanges}
disabled={state.saving()}
>
Discard
</Button>
</Show>
<Button
type="submit"
form={state.formId}
variant="primary"
size="sm"
disabled={state.changes().length === 0}
isLoading={state.saving()}
>
{state.changes().length > 1 ? `Save ${state.changes().length} links` : 'Save'}
</Button>
</div>
</footer>
</div>
</Dialog>
);
};
export default WorkloadWebLinksAction;
export default WorkloadWebLinksDialog;
@@ -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,
@@ -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<typeof renderAction> | 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('');
@@ -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('<Show when={open()}>');
// 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('<TableCardHeader title={props.tableTitle} />');
expect(workloadsSource).toContain('title={props.tableTitle}');
expect(workloadsWorkloadTableSource).toContain(
@@ -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> = {}): WorkloadGuest =>
({
@@ -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<WorkloadWebLinkDrafts>;
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<WorkloadWebLinkFilter>('all');
const [errors, setErrors] = createSignal<Record<string, string>>({});
const [saving, setSaving] = createSignal(false);
const rows = createMemo<WorkloadWebLinkRow[]>(
(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<string, string>) => {
const firstId = rows().find((row) => failed[row.metadataId])?.metadataId;
if (!firstId) return;
queueMicrotask(() =>
Array.from(document.querySelectorAll<HTMLInputElement>(`#${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<string, string> = {};
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<typeof useWorkloadWebLinksState>;
@@ -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<string, GuestMetadata>;
/**
* 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}`];
};
@@ -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<string, GuestMetadata>;
/**
* 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';