Add manual workload column sizing

Refs #1746

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-08-26 13:12:29 +01:00
parent 3414a5abda
commit 5a78231607
30 changed files with 1635 additions and 42 deletions

View file

@ -182,10 +182,14 @@ discarding existing explicit disk exclusions.
`frontend-modern/src/utils/localStorage.ts` is a shared browser-preference key
registry, not deployment state. Workload presentation preferences added there,
including the Proxmox guest-memory comparison basis, must remain optional,
client-local, and backwards-compatible: a missing or invalid stored value
falls back to the shipped guest-allocation view and cannot affect install,
upgrade, update, release, or artifact-selection behavior.
including the Proxmox guest-memory comparison basis and per-surface workload
column widths, must remain optional, client-local, and backwards-compatible. A
missing or invalid stored memory basis falls back to the shipped guest-allocation
view; missing or invalid column widths fall back to the responsive table layout.
The shareable `cols` query parameter may temporarily override the viewer's
column layout, but it must not rewrite their stored preference. None of these
presentation values can affect install, upgrade, update, release, or
artifact-selection behaviour.
1. `frontend-modern/src/api/updates.ts` shared with `api-contracts`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
The version payload consumed by this client must preserve the distinction

View file

@ -50,6 +50,12 @@ stacks. The Proxmox page owns and persists the `Guest` / `Host` memory basis;
the workload state, table, panel, and row contracts carry the selected basis
and resolved parent-node data to the canonical memory bar, and the memory
column header must expose the non-default Host basis after the control closes.
The shared `ColumnPicker` may also expose `Reset widths` when an owning table
has active manual column sizing. That action is separate from restoring column
visibility defaults: feature state owns the width reset callback and active
flag, while the shared picker owns the discoverable menu placement and button
presentation. Tables without manual sizing omit both properties and retain the
existing picker unchanged.
Feature-owned scope controls that use the shared filter rail must keep their
state in the owning route and use stable, domain-authored option identities.

View file

@ -47,6 +47,8 @@ start a goroutine, timer, or notification lifecycle per target.
13. `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
14. `frontend-modern/src/hooks/useWorkloads.ts`
15. `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts`
15a. `frontend-modern/src/components/Workloads/workloadColumnWidths.ts`
15b. `frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts`
16. `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`
17. `frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts`
18. `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`
@ -745,6 +747,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
duplicate hot-path type matching.
16. Extend grouped workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
17. Extend workload control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts` and `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
Manual workload column sizing stays dormant until an operator drags a
header edge at tablet, compact, or wide layout. The first drag snapshots
only the rendered header widths, seeds newly revealed selected columns
from their canonical definitions, and publishes one bounded width map at
pointer release; it must not measure rows, write storage per pointer move,
add global pointer listeners, or change the default responsive
no-horizontal-scroll path. Persisted widths are scoped with the owning
column-visibility preference. A validated `cols` route parameter may own a
shareable ordered layout without overwriting that viewer preference;
unknown ids, invalid widths, oversized lists, and phone/mobile layouts must
fail closed to the ordinary responsive table.
A platform-owned memory comparison preference may be supplied as an
accessor to `useWorkloadsState`. Host-relative sorting must reuse the
bounded parent-node map from `workloadTopology.ts`; it must not scan the
@ -2486,6 +2499,13 @@ the canonical owners for desktop and mobile workload column sizing. Global CSS
must not reintroduce competing `.workload-table [data-workload-col=…]` width
rules or `min-width: max-content` fallbacks that can blow the table out
horizontally on Firefox or other desktop browsers.
Operator-pinned widths remain part of that same contract: the header owns the
pointer gesture, `workloadColumnWidths.ts` owns clamping, snapshots and totals,
`workloadColumnLayoutUrl.ts` owns the bounded URL projection, and
`useWorkloadsControlsState.ts` owns persistence plus reset. Manual mode must
publish the exact sum of visible pinned widths to the existing table scroll
shell, while narrow, phone, and mobile layouts ignore both stored and linked
widths and expose no resize handles.
The mixed Workloads `Info`/compact `ID` column must also remain sortable by the
exact scalar rendered for each workload kind. `guestRowModel.tsx` owns that
projection and `workloadSelectors.ts` owns its natural ordering, including

View file

@ -31,6 +31,10 @@ build path.
- Platform search and row visibility share the same predicates, including
Proxmox nodes and their visible guests. Navigation tabs remain stable across
WebSocket updates instead of remounting with live state.
- Workload table columns can be resized at tablet and desktop widths. A resized
layout keeps every selected column reachable with horizontal scrolling,
persists per surface, and can be shared through the page URL or reset from
the Columns menu without changing the compact default layout.
- Route-level code splitting remains effective on cold start: the frontend no
longer modulepreloads every lazy route chunk, while integrity coverage stays
enforced for dynamically loaded assets.

View file

@ -1,19 +1,51 @@
{
"version": 1,
"base_sha": "08b8144fcff526519a8b7d7706dcc044bebf170c",
"verified_at": "2026-08-26T11:44:58Z",
"base_sha": "3414a5abdad055f99192d44fc4d11becc6d73d7a",
"verified_at": "2026-08-26T12:08:15Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts"
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx",
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts",
"frontend-modern/src/components/Workloads/workloadColumnWidths.ts",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
"frontend-modern/src/components/shared/ColumnPicker.tsx",
"frontend-modern/src/components/shared/columnPickerModel.ts",
"frontend-modern/src/components/shared/useColumnPickerState.ts",
"frontend-modern/src/index.css",
"frontend-modern/src/utils/localStorage.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "63066dea7134226ea5348be26147fd433e56225b429b88854825410dba80111a"
"frontend-modern/src/components/Workloads/GuestRow.tsx": "ca36c0adf46aacefe7f2d40c54fbb682077095e8754b6d6c3322ca73ecdfd310",
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "867aaa1c71471385f3a26d4aeb535e063f33fe32402d44e2695933ee45663416",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "6a3b4e9e7efd702d1f9a9a549865808a1cfb80a259cfb65130beb8779655cc76",
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "564caa9752878f3b416e0953a895ed2113b93090438da64f6c32524336c45c79",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "73e31b9b3c4ac4d28687b71c2f029ae4605c5697ddec19d604e4706c0184cebd",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "955090567fd1302828d7e156e04b43e2c2841ac71f50ee5cd6dac8f3eabece50",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "fb6324c436f3b160d4655fb0d758956ed856c0b811a07c49bdf1a49d7375bb21",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "e40a0a099092a7578f39b433b989c61219a01351f41009ea8fed6ab279f9ea78",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "9a6fd576040ec74f7e45d9af1622a0fad401e18c249ad93449dfa7e808b8250e",
"frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts": "6df93e2f9ca5b7db0d2230cb4786700fe337cf6974630fa1f334e755fa63cc40",
"frontend-modern/src/components/Workloads/workloadColumnWidths.ts": "bb8db7a4891ec714e0ec7c42c821f6106f4a2e52f38bc885b47954d23873eb8a",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "fe413753be3f22165e991630598f89952dfee65f2bdb8d4012e181d308b6f057",
"frontend-modern/src/components/shared/ColumnPicker.tsx": "93570c648a3fd2f8b085019a868715f00e0cb2bf41cdcb89c1fdcc6dafd468b0",
"frontend-modern/src/components/shared/columnPickerModel.ts": "374a27ad36f65531df322f424e8df813260df4d7ad0595b7afced661fcc1aa23",
"frontend-modern/src/components/shared/useColumnPickerState.ts": "548bb62622c4e81728435a3c93536df58d2664afc6bbc3b0a2576f30bbdd3ba6",
"frontend-modern/src/index.css": "7b28bd7056b4f846a9c71cc17f3fdef1f9dfaa2edb4491c91181ebd475129710",
"frontend-modern/src/utils/localStorage.ts": "bd1b11bf5aa0fbf216faf1c7df3bc1b6e6238c607c7ea46e65a69a876dabec60"
},
"routes": ["/docker/overview"],
"routes": ["/proxmox/guests"],
"viewports": [
{
"width": 1230,
"height": 1234
"width": 1280,
"height": 900
},
{
"width": 390,
@ -21,13 +53,17 @@
}
],
"states": [
"explicit Docker host History at 24 hours with utilization, network I/O, disk I/O, and thermal series populated",
"explicit Docker host History at 7 days with the same canonical host chart catalog populated",
"narrow explicit Docker host History with the complete host chart catalog and no collecting placeholders"
"desktop responsive default with eight fitted columns and no horizontal overflow",
"desktop manual layout with all eleven selected columns and horizontal overflow",
"clean shared-link restoration with URL-owned widths and no stored preference",
"column picker explaining the active manual layout and offering Reset widths",
"narrow shared-link fallback with five responsive columns, no resize handles, and no page overflow"
],
"interactions": [
"expanded auth-service-01 from the Docker host table and selected History; confirmed Utilization, Network I/O, Disk I/O, and Thermals with zero Collecting history placeholders",
"switched the host History selector to 7 days and back to 24 hours; all four canonical host groups remained populated",
"rechecked auth-service-01 at 390 by 844 and scrolled through Thermals; charts, range control, table rows, and page navigation remained visible and usable"
"dragged the Name header edge from 324 to 172 pixels; confirmed the cols URL, stored widths, eleven resize handles, and a 1420-pixel table inside a 1173-pixel scroll shell",
"cleared the workload-width preference and reloaded the shared cols URL; confirmed the 172-pixel Name width and complete manual layout without adopting the URL as a stored preference",
"opened View then Columns and selected Reset widths; confirmed the clean route, 324-pixel responsive Name width, eight handles, and no horizontal overflow",
"loaded the same shared cols URL at 390 by 844; confirmed the responsive five-column table, zero resize handles, and a document width equal to the viewport",
"inspected the browser console after desktop and narrow checks; no errors were reported"
]
}

View file

@ -233,6 +233,14 @@ describe('App architecture', () => {
expect(appStylesSource).not.toContain('white-space: normal;\n -webkit-box-orient');
});
it('keeps manual workload widths inside the existing horizontal table shell', () => {
expect(appStylesSource).toContain('.table-scroll-shell > table.workload-table--manual-widths');
expect(appStylesSource).toContain('.workload-col-resizer');
expect(appStylesSource).toContain('cursor: col-resize');
expect(appStylesSource).toContain('touch-action: none');
expect(appStylesSource).toContain('@media (prefers-reduced-motion: reduce)');
});
it('keeps native form and browser autofill paint on semantic theme tokens', () => {
expect(appStylesSource).toContain('color-scheme: light');
expect(appStylesSource).toContain('color-scheme: dark');

View file

@ -210,6 +210,7 @@ export function GuestRow(props: GuestRowProps) {
isMobile(),
props.workloadTableLayoutMode,
props.visibleColumnIds,
props.columnWidths,
)}
>
<div

View file

@ -77,6 +77,7 @@ type WorkloadPanelProps = Pick<
| 'workloadMetricHistory'
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadColumnWidths'
| 'workloadTableVisibleColumns'
>;
@ -579,6 +580,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
onCustomUrlUpdate={props.handleCustomUrlUpdate}
isGroupedView={props.groupingMode() === 'grouped'}
visibleColumnIds={props.workloadTableVisibleColumnIds()}
columnWidths={props.workloadColumnWidths()}
workloadTableLayoutMode={props.workloadTableLayoutMode()}
onClick={() =>
props.setSelectedGuestId(

View file

@ -1,4 +1,4 @@
import { For } from 'solid-js';
import { For, Show } from 'solid-js';
import { TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
@ -9,6 +9,7 @@ import {
import type { PlatformTableCellAlign } from '@/features/platformPage/sharedPlatformPage';
import { getGuestColumnStyle } from './guestRowModel';
import { shouldEngageColumnResize, WORKLOAD_COLUMN_MIN_WIDTH } from './workloadColumnWidths';
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
import type { WorkloadsState, WorkloadSortKey } from './useWorkloadsState';
@ -23,6 +24,13 @@ type WorkloadTableHeaderProps = Pick<
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadTableVisibleColumns'
| 'workloadColumnWidths'
| 'workloadManualColumnSizingSupported'
| 'beginWorkloadColumnResize'
| 'previewWorkloadColumnWidth'
| 'commitWorkloadColumnResize'
| 'cancelWorkloadColumnResize'
| 'clearWorkloadColumnWidth'
>;
export const getWorkloadColumnHeaderLabel = (
@ -72,6 +80,17 @@ const resolveAlignClasses = (
return { textAlign: 'text-left', flexJustify: 'justify-start' };
};
const measureRenderedColumnWidths = (headerCell: HTMLElement): Record<string, number> => {
const widths: Record<string, number> = {};
const row = headerCell.closest('tr');
if (!row) return widths;
for (const cell of Array.from(row.querySelectorAll<HTMLElement>('th[data-workload-col]'))) {
const columnId = cell.dataset.workloadCol;
if (columnId) widths[columnId] = cell.getBoundingClientRect().width;
}
return widths;
};
export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
return (
<TableHeader>
@ -95,10 +114,67 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
props.workloadMemoryDisplayBasis(),
usesCompactHeader(),
);
const isResizable = () => props.workloadManualColumnSizingSupported();
let headerCell: HTMLTableCellElement | undefined;
let dragPointerId: number | null = null;
let dragStartX = 0;
let dragStartWidth = 0;
let dragMeasuredWidths: Record<string, number> = {};
let dragEngaged = false;
const endDrag = (handle: HTMLElement): void => {
if (dragPointerId === null) return;
if (handle.hasPointerCapture(dragPointerId))
handle.releasePointerCapture(dragPointerId);
dragPointerId = null;
};
const onPointerDown = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
if (!isResizable() || event.button !== 0 || !headerCell) return;
event.preventDefault();
event.stopPropagation();
dragPointerId = event.pointerId;
dragStartX = event.clientX;
dragStartWidth = headerCell.getBoundingClientRect().width;
dragMeasuredWidths = measureRenderedColumnWidths(headerCell);
dragEngaged = false;
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
const delta = event.clientX - dragStartX;
if (!dragEngaged) {
if (!shouldEngageColumnResize(delta, false)) return;
dragEngaged = true;
props.beginWorkloadColumnResize(dragMeasuredWidths);
}
event.preventDefault();
props.previewWorkloadColumnWidth(
col.id,
Math.max(WORKLOAD_COLUMN_MIN_WIDTH, dragStartWidth + delta),
);
};
const onPointerUp = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
event.preventDefault();
event.stopPropagation();
const engaged = dragEngaged;
dragEngaged = false;
endDrag(event.currentTarget);
if (engaged) props.commitWorkloadColumnResize();
};
const onPointerCancel = (
event: PointerEvent & { currentTarget: HTMLElement },
): void => {
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
dragEngaged = false;
endDrag(event.currentTarget);
props.cancelWorkloadColumnResize();
};
return (
<TableHead
class={`py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
ref={headerCell}
class={`relative py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-2 sm:pl-3 pr-1.5 sm:pr-2' : 'px-1.5 sm:px-2'} ${alignClasses().textAlign} align-middle
${isSortable ? 'cursor-pointer hover:bg-surface-hover' : ''}`}
data-workload-col={col.id}
@ -107,6 +183,7 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
props.isMobile(),
props.workloadTableLayoutMode(),
props.workloadTableVisibleColumnIds(),
props.workloadColumnWidths(),
)}
onClick={() => isSortable && props.handleSort(sortKeyForCol!)}
title={
@ -130,6 +207,25 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
)}
{getTableSortIndicator(Boolean(isSorted()), props.sortDirection())}
</div>
<Show when={isResizable()}>
<span
class="workload-col-resizer"
data-workload-col-resizer={col.id}
role="presentation"
aria-hidden="true"
title={`Drag to resize · double-click to reset ${col.label}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onDblClick={(event) => {
event.preventDefault();
event.stopPropagation();
props.clearWorkloadColumnWidth(col.id);
}}
onClick={(event) => event.stopPropagation()}
/>
</Show>
</TableHead>
);
}}

View file

@ -418,6 +418,8 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
onToggle={visibility().onColumnToggle}
onReset={visibility().onColumnReset}
showReset={visibility().showReset}
onResetWidths={visibility().onColumnWidthsReset}
hasManualWidths={visibility().hasColumnWidthOverrides}
/>
</div>
)}

View file

@ -231,6 +231,15 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
workloadTableLayoutMode={state.workloadTableLayoutMode}
workloadTableVisibleColumnIds={state.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={state.workloadTableVisibleColumns}
workloadColumnWidths={state.workloadColumnWidths}
workloadManualColumnSizing={state.workloadManualColumnSizing}
workloadManualColumnSizingSupported={state.workloadManualColumnSizingSupported}
workloadTableManualWidth={state.workloadTableManualWidth}
beginWorkloadColumnResize={state.beginWorkloadColumnResize}
previewWorkloadColumnWidth={state.previewWorkloadColumnWidth}
commitWorkloadColumnResize={state.commitWorkloadColumnResize}
cancelWorkloadColumnResize={state.cancelWorkloadColumnResize}
clearWorkloadColumnWidth={state.clearWorkloadColumnWidth}
title={props.tableTitle}
/>
</Show>

View file

@ -61,6 +61,15 @@ type WorkloadsTableProps = Pick<
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadTableVisibleColumns'
| 'workloadColumnWidths'
| 'workloadManualColumnSizing'
| 'workloadManualColumnSizingSupported'
| 'workloadTableManualWidth'
| 'beginWorkloadColumnResize'
| 'previewWorkloadColumnWidth'
| 'commitWorkloadColumnResize'
| 'cancelWorkloadColumnResize'
| 'clearWorkloadColumnWidth'
> & {
title?: JSX.Element;
};
@ -77,7 +86,12 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
<TableCardHeader title={props.title} />
<Table
wrapperRef={props.setTableWrapperRef}
class={`platform-table workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}`}
class={`platform-table workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}${props.workloadManualColumnSizing() ? ' workload-table--manual-widths' : ''}`}
style={
props.workloadTableManualWidth() === null
? undefined
: { width: `${props.workloadTableManualWidth()}px` }
}
>
<colgroup>
<For each={props.workloadTableVisibleColumns()}>
@ -89,6 +103,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
props.isMobile(),
props.workloadTableLayoutMode(),
props.workloadTableVisibleColumnIds(),
props.workloadColumnWidths(),
)}
/>
)}
@ -104,6 +119,13 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
workloadColumnWidths={props.workloadColumnWidths}
workloadManualColumnSizingSupported={props.workloadManualColumnSizingSupported}
beginWorkloadColumnResize={props.beginWorkloadColumnResize}
previewWorkloadColumnWidth={props.previewWorkloadColumnWidth}
commitWorkloadColumnResize={props.commitWorkloadColumnResize}
cancelWorkloadColumnResize={props.cancelWorkloadColumnResize}
clearWorkloadColumnWidth={props.clearWorkloadColumnWidth}
/>
<WorkloadPanel
activeAlerts={props.activeAlerts}
@ -147,6 +169,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
workloadColumnWidths={props.workloadColumnWidths}
/>
</Table>
</TableCard>

View file

@ -22,6 +22,8 @@ import workloadFilterConfigModelSource from '../workloadFilterConfigModel.ts?raw
import workloadRouteModelSource from '../workloadRouteModel.ts?raw';
import workloadRouteStateModelSource from '../workloadRouteStateModel.ts?raw';
import workloadUrlSyncModelSource from '../workloadUrlSyncModel.ts?raw';
import workloadColumnLayoutUrlSource from '../workloadColumnLayoutUrl.ts?raw';
import workloadColumnWidthsSource from '../workloadColumnWidths.ts?raw';
import workloadsWorkloadViewportSyncSource from '../useWorkloadViewportSync.ts?raw';
import workloadsWorkloadRouteStateSource from '../useWorkloadRouteState.ts?raw';
import workloadsWorkloadUrlSyncSource from '../useWorkloadUrlSync.ts?raw';
@ -1646,6 +1648,8 @@ describe('Workloads performance contract', () => {
expect(workloadsWorkloadTableSource).toContain('WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS');
expect(workloadsWorkloadTableSource).toContain('props.workloadTableLayoutMode()');
expect(workloadsWorkloadTableSource).toContain('props.workloadTableVisibleColumnIds()');
expect(workloadsWorkloadTableSource).toContain('workload-table--manual-widths');
expect(workloadsWorkloadTableSource).toContain('props.workloadTableManualWidth()');
expect(workloadsWorkloadTableSource).toContain('<colgroup>');
expect(workloadsWorkloadTableSource).not.toContain('<TableHead');
expect(workloadsWorkloadTableSource).not.toContain('NodeGroupHeader');
@ -1663,6 +1667,21 @@ describe('Workloads performance contract', () => {
expect(workloadTableHeaderSource).toContain('props.workloadTableVisibleColumnIds()');
expect(workloadTableHeaderSource).toContain('aria-hidden="true"');
expect(workloadTableHeaderSource).toContain('<span class="sr-only">{label()}</span>');
expect(workloadTableHeaderSource).toContain('data-workload-col-resizer={col.id}');
expect(workloadTableHeaderSource).toContain('event.currentTarget.setPointerCapture');
expect(workloadTableHeaderSource).toContain('onPointerCancel={onPointerCancel}');
expect(workloadColumnWidthsSource).toContain(
'const MANUAL_SIZING_LAYOUT_MODES: ReadonlySet<WorkloadTableLayoutMode>',
);
expect(workloadColumnWidthsSource).toContain("'tablet'");
expect(workloadColumnWidthsSource).toContain("'compact'");
expect(workloadColumnWidthsSource).toContain("'wide'");
expect(workloadColumnWidthsSource).not.toContain("'phone'");
expect(workloadColumnWidthsSource).toContain('sumWorkloadColumnWidths');
expect(workloadColumnLayoutUrlSource).toContain(
"export const WORKLOAD_COLUMNS_URL_PARAM = 'cols'",
);
expect(workloadColumnLayoutUrlSource).toContain('resolveWorkloadColumnLayoutEntries');
expect(workloadTableHeaderSource).not.toContain('style={{');
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('NodeGroupHeader');

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import {
GUEST_COLUMNS,
getGuestColumnStyle,
getGuestColumnWidthStyle,
getWorkloadVisibleColumnsForLayout,
} from '../guestRowModel';
const columnsFor = (ids: readonly string[]): ColumnDef[] =>
ids.map((id) => GUEST_COLUMNS.find((column) => column.id === id)!).filter(Boolean);
// The wide-only trio is what operators lose first on a laptop-width table.
const SELECTED_COLUMN_IDS = [
'name',
'type',
'info',
'cpu',
'memory',
'disk',
'uptime',
'tags',
'netIo',
'diskIo',
];
describe('workload column manual sizing', () => {
describe('getWorkloadVisibleColumnsForLayout', () => {
it('drops wide-only columns on a compact table by default', () => {
const visible = getWorkloadVisibleColumnsForLayout(
columnsFor(SELECTED_COLUMN_IDS),
'compact',
).map((column) => column.id);
expect(visible).not.toContain('netIo');
expect(visible).not.toContain('diskIo');
expect(visible).not.toContain('tags');
expect(visible).toContain('cpu');
});
it('keeps every selected column once the operator has pinned a width', () => {
const visible = getWorkloadVisibleColumnsForLayout(
columnsFor(SELECTED_COLUMN_IDS),
'compact',
{ manualSizing: true },
).map((column) => column.id);
expect(visible).toEqual(SELECTED_COLUMN_IDS);
});
it('still drops wide-only columns on touch layouts even with widths persisted', () => {
for (const layoutMode of ['narrow', 'phone', 'mobile'] as const) {
const visible = getWorkloadVisibleColumnsForLayout(
columnsFor(SELECTED_COLUMN_IDS),
layoutMode,
{ manualSizing: true },
).map((column) => column.id);
expect(visible, layoutMode).not.toContain('netIo');
}
});
it('is unchanged when manualSizing is absent or false', () => {
const base = getWorkloadVisibleColumnsForLayout(columnsFor(SELECTED_COLUMN_IDS), 'compact');
const explicitlyOff = getWorkloadVisibleColumnsForLayout(
columnsFor(SELECTED_COLUMN_IDS),
'compact',
{ manualSizing: false },
);
expect(explicitlyOff.map((column) => column.id)).toEqual(base.map((column) => column.id));
});
});
describe('getGuestColumnStyle', () => {
it('leaves the responsive weights untouched when nothing is pinned', () => {
const withoutOverrides = getGuestColumnStyle('name', false, 'compact', SELECTED_COLUMN_IDS);
const withEmptyOverrides = getGuestColumnStyle(
'name',
false,
'compact',
SELECTED_COLUMN_IDS,
{},
);
expect(withEmptyOverrides).toEqual(withoutOverrides);
expect(String(withoutOverrides?.width)).toContain('%');
});
it('pins width, min-width and max-width together so table-fixed cannot redistribute', () => {
const style = getGuestColumnStyle('name', false, 'compact', SELECTED_COLUMN_IDS, {
name: 240,
});
expect(style).toMatchObject({
width: '240px',
'min-width': '240px',
'max-width': '240px',
});
});
it('only overrides the pinned column', () => {
const overrides = { name: 240 };
const cpu = getGuestColumnStyle('cpu', false, 'compact', SELECTED_COLUMN_IDS, overrides);
expect(String(cpu?.width)).toContain('%');
});
it('feeds the same pinned width to the colgroup', () => {
expect(
getGuestColumnWidthStyle('netIo', false, 'compact', SELECTED_COLUMN_IDS, { netIo: 210 }),
).toEqual({ width: '210px' });
});
});
});

View file

@ -455,4 +455,230 @@ describe('useWorkloadsControlsState', () => {
}
});
});
describe('manual column sizing', () => {
const withState = (run: (state: ReturnType<typeof useWorkloadsControlsState>) => void) =>
createRoot((dispose) => {
try {
const [showFilters, setShowFilters] = createSignal(false);
run(
useWorkloadsControlsState({
viewMode: () => 'all' as ViewMode,
showFilters,
setShowFilters,
}),
);
} finally {
dispose();
}
});
it('is inert until a column edge is dragged', () => {
setCompactViewport();
withState((state) => {
expect(state.workloadManualColumnSizing()).toBe(false);
expect(state.workloadColumnWidths()).toEqual({});
expect(state.workloadTableManualWidth()).toBeNull();
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
});
});
it("reveals the operator's full column selection once a width is pinned", () => {
setCompactViewport();
withState((state) => {
state.beginWorkloadColumnResize({ name: 302, cpu: 151 });
state.previewWorkloadColumnWidth('name', 172);
state.commitWorkloadColumnResize();
expect(state.workloadManualColumnSizing()).toBe(true);
expect(state.workloadColumnWidths().name).toBe(172);
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
expect(state.workloadTableVisibleColumnIds()).toContain('diskIo');
});
});
it('pins every rendered column so the published table width is complete', () => {
setCompactViewport();
withState((state) => {
state.beginWorkloadColumnResize({ name: 302 });
state.previewWorkloadColumnWidth('name', 172);
state.commitWorkloadColumnResize();
const widths = state.workloadColumnWidths();
for (const columnId of state.workloadTableVisibleColumnIds()) {
expect(widths[columnId], columnId).toBeGreaterThan(0);
}
expect(state.workloadTableManualWidth()).toBe(
state.workloadTableVisibleColumnIds().reduce((total, id) => total + widths[id], 0),
);
});
});
it('discards an abandoned drag without persisting anything', () => {
setCompactViewport();
withState((state) => {
state.beginWorkloadColumnResize({ name: 302 });
state.previewWorkloadColumnWidth('name', 172);
state.cancelWorkloadColumnResize();
expect(state.workloadManualColumnSizing()).toBe(false);
expect(state.workloadColumnWidths()).toEqual({});
});
});
it('restores one column to its design width rather than leaving it unpinned', () => {
// A deleted pin inside an already fully claimed table-fixed table
// collapses that column to zero width.
setCompactViewport();
withState((state) => {
state.beginWorkloadColumnResize({ name: 302 });
state.previewWorkloadColumnWidth('name', 172);
state.commitWorkloadColumnResize();
state.clearWorkloadColumnWidth('name');
expect(state.workloadManualColumnSizing()).toBe(true);
expect(state.workloadColumnWidths().name).toBe(200);
expect(state.workloadTableManualWidth()).not.toBeNull();
});
});
it('returns to the responsive layout when the last pin is cleared', () => {
setCompactViewport();
withState((state) => {
state.beginWorkloadColumnResize({ name: 302 });
state.previewWorkloadColumnWidth('name', 172);
state.commitWorkloadColumnResize();
state.resetWorkloadColumnWidths();
expect(state.workloadManualColumnSizing()).toBe(false);
expect(state.workloadColumnWidths()).toEqual({});
expect(state.workloadTableManualWidth()).toBeNull();
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
});
});
it('never engages on touch layouts, even with widths already persisted', () => {
localStorage.setItem('workloadsColumnWidths', JSON.stringify({ name: 172 }));
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
withState((state) => {
expect(state.workloadManualColumnSizingSupported()).toBe(false);
expect(state.workloadManualColumnSizing()).toBe(false);
expect(state.workloadColumnWidths()).toEqual({});
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
});
});
});
describe('shareable column layout (?cols=)', () => {
const withState = (run: (state: ReturnType<typeof useWorkloadsControlsState>) => void) =>
createRoot((dispose) => {
try {
const [showFilters, setShowFilters] = createSignal(false);
run(
useWorkloadsControlsState({
viewMode: () => 'all' as ViewMode,
showFilters,
setShowFilters,
}),
);
} finally {
dispose();
}
});
const currentCols = () => new URLSearchParams(mockRouterSearch()).get('cols');
it('reproduces the linked table on a profile that has never resized anything', () => {
setCompactViewport();
setMockRouterSearch('?cols=name:172,cpu:151,netIo:170');
withState((state) => {
expect(localStorage.getItem('workloadsColumnWidths')).toBeNull();
expect(state.workloadManualColumnSizing()).toBe(true);
expect(state.workloadTableVisibleColumnIds()).toEqual(['name', 'cpu', 'netIo']);
expect(state.workloadColumnWidths()).toEqual({ name: 172, cpu: 151, netIo: 170 });
});
});
it("lets the link win over the viewer's stored widths", () => {
setCompactViewport();
localStorage.setItem('workloadsColumnWidths', JSON.stringify({ name: 500, cpu: 500 }));
setMockRouterSearch('?cols=name:172,cpu:151');
withState((state) => {
expect(state.workloadColumnWidths()).toEqual({ name: 172, cpu: 151 });
});
});
it('ignores columns the link names that this view cannot render', () => {
setCompactViewport();
setMockRouterSearch('?cols=name:172,totallyUnknown:120,cpu:151');
withState((state) => {
expect(state.workloadTableVisibleColumnIds()).toEqual(['name', 'cpu']);
});
});
it('publishes the layout to the URL when a drag is committed', () => {
setCompactViewport();
withState((state) => {
expect(currentCols()).toBeNull();
state.beginWorkloadColumnResize({ name: 302 });
state.previewWorkloadColumnWidth('name', 172);
state.commitWorkloadColumnResize();
const cols = currentCols();
expect(cols).toContain('name:172');
expect(cols).toContain('netIo:');
});
});
it("edits the link rather than the viewer's saved widths while a link is active", () => {
setCompactViewport();
setMockRouterSearch('?cols=name:172,cpu:151');
withState((state) => {
state.beginWorkloadColumnResize({ name: 172, cpu: 151 });
state.previewWorkloadColumnWidth('cpu', 260);
state.commitWorkloadColumnResize();
expect(currentCols()).toBe('name:172,cpu:260');
expect(localStorage.getItem('workloadsColumnWidths')).toBeNull();
});
});
it('adds and removes columns through the link, leaving the stored preference alone', () => {
setCompactViewport();
setMockRouterSearch('?cols=name:172,cpu:151');
withState((state) => {
const before = state.columnVisibility.hiddenColumns().slice();
state.workloadsFilterColumnVisibility().onColumnToggle('netIo');
expect(currentCols()).toBe('name:172,cpu:151,netIo:170');
state.workloadsFilterColumnVisibility().onColumnToggle('netIo');
expect(currentCols()).toBe('name:172,cpu:151');
expect(state.columnVisibility.hiddenColumns()).toEqual(before);
});
});
it('drops the parameter when widths are reset', () => {
setCompactViewport();
setMockRouterSearch('?cols=name:172,cpu:151');
withState((state) => {
state.resetWorkloadColumnWidths();
expect(currentCols()).toBeNull();
expect(state.workloadManualColumnSizing()).toBe(false);
});
});
it('ignores a linked layout on touch layouts', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
setMockRouterSearch('?cols=name:172,cpu:151,netIo:170');
withState((state) => {
expect(state.workloadManualColumnSizing()).toBe(false);
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
});
});
});
});

View file

@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
buildWorkloadColumnLayoutEntries,
parseWorkloadColumnLayoutParam,
resolveWorkloadColumnLayoutEntries,
serializeWorkloadColumnLayout,
toggleWorkloadColumnLayoutEntry,
workloadColumnLayoutIds,
workloadColumnLayoutWidths,
WORKLOAD_COLUMNS_URL_PARAM,
} from '../workloadColumnLayoutUrl';
import { WORKLOAD_COLUMN_MAX_WIDTH, WORKLOAD_COLUMN_MIN_WIDTH } from '../workloadColumnWidths';
describe('workloadColumnLayoutUrl', () => {
it('uses a single compact query parameter', () => {
expect(WORKLOAD_COLUMNS_URL_PARAM).toBe('cols');
});
describe('parseWorkloadColumnLayoutParam', () => {
it('reads ordered id:width pairs', () => {
expect(parseWorkloadColumnLayoutParam('name:172,cpu:151,netIo:170')).toEqual([
{ id: 'name', width: 172 },
{ id: 'cpu', width: 151 },
{ id: 'netIo', width: 170 },
]);
});
it('treats an absent or empty parameter as no layout', () => {
expect(parseWorkloadColumnLayoutParam(null)).toEqual([]);
expect(parseWorkloadColumnLayoutParam(undefined)).toEqual([]);
expect(parseWorkloadColumnLayoutParam('')).toEqual([]);
});
it('skips malformed pairs rather than rejecting the whole link', () => {
expect(
parseWorkloadColumnLayoutParam('name:172,broken,cpu:,:80,memory:abc,disk:163'),
).toEqual([
{ id: 'name', width: 172 },
{ id: 'disk', width: 163 },
]);
});
it('rejects ids that are not plain column identifiers', () => {
expect(parseWorkloadColumnLayoutParam('<script>:100,na me:100,name:172')).toEqual([
{ id: 'name', width: 172 },
]);
});
it('keeps the first entry when a link repeats a column', () => {
expect(parseWorkloadColumnLayoutParam('name:172,name:400')).toEqual([
{ id: 'name', width: 172 },
]);
});
it('clamps widths from a hand-edited link', () => {
expect(parseWorkloadColumnLayoutParam('name:1,cpu:99999')).toEqual([
{ id: 'name', width: WORKLOAD_COLUMN_MIN_WIDTH },
{ id: 'cpu', width: WORKLOAD_COLUMN_MAX_WIDTH },
]);
});
it('caps how many columns a link can request', () => {
const raw = Array.from({ length: 60 }, (_, index) => `c${index}:100`).join(',');
expect(parseWorkloadColumnLayoutParam(raw)).toHaveLength(32);
});
});
describe('serializeWorkloadColumnLayout', () => {
it('round-trips through the parser', () => {
const entries = [
{ id: 'name', width: 172 },
{ id: 'netIo', width: 170 },
];
expect(serializeWorkloadColumnLayout(entries)).toBe('name:172,netIo:170');
expect(parseWorkloadColumnLayoutParam(serializeWorkloadColumnLayout(entries))).toEqual(
entries,
);
});
});
describe('resolveWorkloadColumnLayoutEntries', () => {
it('drops columns this view cannot render but keeps the link order', () => {
const entries = [
{ id: 'netIo', width: 170 },
{ id: 'vsphereOnly', width: 120 },
{ id: 'name', width: 172 },
];
expect(
resolveWorkloadColumnLayoutEntries(entries, new Set(['name', 'netIo', 'cpu'])),
).toEqual([
{ id: 'netIo', width: 170 },
{ id: 'name', width: 172 },
]);
});
});
describe('buildWorkloadColumnLayoutEntries', () => {
it('emits the rendered order and skips columns with no pinned width', () => {
expect(
buildWorkloadColumnLayoutEntries(['name', 'cpu', 'memory'], { name: 172, memory: 163 }),
).toEqual([
{ id: 'name', width: 172 },
{ id: 'memory', width: 163 },
]);
});
});
describe('workloadColumnLayoutWidths / Ids', () => {
it('projects entries for the render path', () => {
const entries = [
{ id: 'name', width: 172 },
{ id: 'cpu', width: 151 },
];
expect(workloadColumnLayoutWidths(entries)).toEqual({ name: 172, cpu: 151 });
expect(workloadColumnLayoutIds(entries)).toEqual(['name', 'cpu']);
});
});
describe('toggleWorkloadColumnLayoutEntry', () => {
it('appends a new column at its design width', () => {
expect(toggleWorkloadColumnLayoutEntry([{ id: 'name', width: 172 }], 'netIo', 170)).toEqual([
{ id: 'name', width: 172 },
{ id: 'netIo', width: 170 },
]);
});
it('removes a column that is already in the link', () => {
expect(
toggleWorkloadColumnLayoutEntry(
[
{ id: 'name', width: 172 },
{ id: 'netIo', width: 170 },
],
'netIo',
170,
),
).toEqual([{ id: 'name', width: 172 }]);
});
it('refuses to empty the table', () => {
expect(toggleWorkloadColumnLayoutEntry([{ id: 'name', width: 172 }], 'name', 200)).toEqual([
{ id: 'name', width: 172 },
]);
});
});
});

View file

@ -0,0 +1,214 @@
import { describe, expect, it } from 'vitest';
import {
clampWorkloadColumnWidth,
shouldEngageColumnResize,
hasWorkloadColumnWidths,
isWorkloadManualSizingSupported,
normalizeWorkloadColumnWidths,
pruneWorkloadColumnWidths,
snapshotWorkloadColumnWidths,
withWorkloadColumnWidth,
withoutWorkloadColumnWidth,
seedWorkloadColumnWidthsFromDefaults,
sumWorkloadColumnWidths,
workloadColumnWidthsStorageKey,
WORKLOAD_COLUMN_FALLBACK_WIDTH,
WORKLOAD_COLUMN_MAX_WIDTH,
WORKLOAD_COLUMN_MIN_WIDTH,
} from '../workloadColumnWidths';
describe('workloadColumnWidths', () => {
describe('clampWorkloadColumnWidth', () => {
it('keeps a column wide enough to stay clickable and narrow enough to stay usable', () => {
expect(clampWorkloadColumnWidth(1)).toBe(WORKLOAD_COLUMN_MIN_WIDTH);
expect(clampWorkloadColumnWidth(10_000)).toBe(WORKLOAD_COLUMN_MAX_WIDTH);
expect(clampWorkloadColumnWidth(180.4)).toBe(180);
});
});
describe('shouldEngageColumnResize', () => {
it('treats a press with no travel as a click, not a resize', () => {
// Committing a zero-delta drag would pin every width and switch the
// table into manual sizing without the operator asking for it.
expect(shouldEngageColumnResize(0, false)).toBe(false);
expect(shouldEngageColumnResize(2, false)).toBe(false);
expect(shouldEngageColumnResize(-2, false)).toBe(false);
});
it('engages once the pointer has actually moved, in either direction', () => {
expect(shouldEngageColumnResize(3, false)).toBe(true);
expect(shouldEngageColumnResize(-40, false)).toBe(true);
});
it('stays engaged for the rest of the drag even when the pointer returns', () => {
expect(shouldEngageColumnResize(0, true)).toBe(true);
});
it('ignores a non-finite delta', () => {
expect(shouldEngageColumnResize(Number.NaN, false)).toBe(false);
});
});
describe('isWorkloadManualSizingSupported', () => {
it('is offered on pointer-sized layouts only', () => {
expect(isWorkloadManualSizingSupported('wide')).toBe(true);
expect(isWorkloadManualSizingSupported('compact')).toBe(true);
expect(isWorkloadManualSizingSupported('tablet')).toBe(true);
expect(isWorkloadManualSizingSupported('mobile')).toBe(false);
expect(isWorkloadManualSizingSupported('phone')).toBe(false);
expect(isWorkloadManualSizingSupported('narrow')).toBe(false);
});
});
describe('normalizeWorkloadColumnWidths', () => {
it('returns an empty map for anything that is not a width record', () => {
expect(normalizeWorkloadColumnWidths(null)).toEqual({});
expect(normalizeWorkloadColumnWidths(undefined)).toEqual({});
expect(normalizeWorkloadColumnWidths('name')).toEqual({});
expect(normalizeWorkloadColumnWidths(['name'])).toEqual({});
});
it('drops unusable entries instead of throwing so one bad key cannot wedge the table', () => {
expect(
normalizeWorkloadColumnWidths({
name: 240,
cpu: 'not-a-number',
memory: Number.NaN,
disk: 0,
netIo: -20,
'': 100,
diskIo: '180',
}),
).toEqual({ name: 240, diskIo: 180 });
});
it('clamps persisted widths that fall outside the supported range', () => {
expect(normalizeWorkloadColumnWidths({ name: 5, cpu: 5_000 })).toEqual({
name: WORKLOAD_COLUMN_MIN_WIDTH,
cpu: WORKLOAD_COLUMN_MAX_WIDTH,
});
});
});
describe('hasWorkloadColumnWidths', () => {
it('treats an empty or missing map as untouched', () => {
expect(hasWorkloadColumnWidths(undefined)).toBe(false);
expect(hasWorkloadColumnWidths({})).toBe(false);
expect(hasWorkloadColumnWidths({ name: 200 })).toBe(true);
});
});
describe('withWorkloadColumnWidth', () => {
it('pins a clamped width without mutating the previous map', () => {
const current = { name: 200 };
const next = withWorkloadColumnWidth(current, 'cpu', 12);
expect(next).toEqual({ name: 200, cpu: WORKLOAD_COLUMN_MIN_WIDTH });
expect(current).toEqual({ name: 200 });
});
it('ignores an unusable column id or width', () => {
const current = { name: 200 };
expect(withWorkloadColumnWidth(current, ' ', 120)).toBe(current);
expect(withWorkloadColumnWidth(current, 'cpu', Number.NaN)).toBe(current);
});
});
describe('withoutWorkloadColumnWidth', () => {
it('clears one pin and leaves the map identical when there was nothing to clear', () => {
const current = { name: 200, cpu: 140 };
expect(withoutWorkloadColumnWidth(current, 'cpu')).toEqual({ name: 200 });
expect(withoutWorkloadColumnWidth(current, 'memory')).toBe(current);
});
});
describe('pruneWorkloadColumnWidths', () => {
it('drops pins for columns the operator has since hidden', () => {
expect(pruneWorkloadColumnWidths({ name: 200, cpu: 140 }, ['name'])).toEqual({ name: 200 });
});
it('returns the same reference when every pin is still rendered', () => {
const current = { name: 200, cpu: 140 };
expect(pruneWorkloadColumnWidths(current, ['name', 'cpu', 'memory'])).toBe(current);
});
});
describe('snapshotWorkloadColumnWidths', () => {
it('freezes the widths the operator can currently see', () => {
expect(snapshotWorkloadColumnWidths({}, { name: 302.4, cpu: 151.2 })).toEqual({
name: 302,
cpu: 151,
});
});
it('never overwrites a width the operator already pinned', () => {
expect(snapshotWorkloadColumnWidths({ name: 240 }, { name: 302, cpu: 151 })).toEqual({
name: 240,
cpu: 151,
});
});
it('skips columns the browser reported no usable width for', () => {
expect(snapshotWorkloadColumnWidths({}, { name: 0, cpu: Number.NaN, disk: 140 })).toEqual({
disk: 140,
});
});
});
describe('seedWorkloadColumnWidthsFromDefaults', () => {
it('gives columns that were never on screen their design width', () => {
expect(
seedWorkloadColumnWidthsFromDefaults({ name: 172 }, [
{ id: 'name', width: '200px' },
{ id: 'netIo', width: '170px', minWidth: '170px' },
{ id: 'tags', width: '60px' },
]),
).toEqual({ name: 172, netIo: 170, tags: 60 });
});
it('falls back to minWidth, then to a usable default, for columns with no design width', () => {
expect(
seedWorkloadColumnWidthsFromDefaults({}, [
{ id: 'a', minWidth: '96px' },
{ id: 'b' },
{ id: 'c', width: '50%' },
]),
).toEqual({
a: 96,
b: WORKLOAD_COLUMN_FALLBACK_WIDTH,
c: WORKLOAD_COLUMN_FALLBACK_WIDTH,
});
});
});
describe('sumWorkloadColumnWidths', () => {
it('publishes the exact total so table-layout: fixed keeps applying', () => {
expect(sumWorkloadColumnWidths({ name: 172, cpu: 151, memory: 163 }, ['name', 'cpu'])).toBe(
323,
);
});
it('returns null while nothing is pinned so the percentage layout is untouched', () => {
expect(sumWorkloadColumnWidths({}, ['name', 'cpu'])).toBeNull();
expect(sumWorkloadColumnWidths({ name: 172 }, [])).toBeNull();
});
it('returns null when any rendered column is still unpinned', () => {
// A partial total would under-size the table and let the browser
// redistribute, which is exactly what the pins are meant to prevent.
expect(sumWorkloadColumnWidths({ name: 172 }, ['name', 'cpu'])).toBeNull();
});
});
describe('workloadColumnWidthsStorageKey', () => {
it('scopes the key per surface so Proxmox and vSphere do not share widths', () => {
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths')).toBe('workloadsColumnWidths');
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths', ' proxmox ')).toBe(
'workloadsColumnWidths:proxmox',
);
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths', ' ')).toBe(
'workloadsColumnWidths',
);
});
});
});

View file

@ -16,6 +16,7 @@ import type {
WorkloadTableMetric,
} from './workloadMetricHistoryModel';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import type { WorkloadColumnWidths } from './workloadColumnWidths';
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
export type WorkloadTableLayoutMode = 'narrow' | 'phone' | 'mobile' | 'tablet' | 'compact' | 'wide';
@ -113,6 +114,7 @@ export interface GuestRowProps {
onCustomUrlUpdate?: (guestId: string, url: string) => void;
isGroupedView?: boolean;
visibleColumnIds?: string[];
columnWidths?: WorkloadColumnWidths;
onClick?: () => void;
isExpanded?: boolean;
isSummaryHighlighted?: boolean;
@ -557,6 +559,7 @@ const getGuestColumnSizing = (
isMobile = false,
layoutMode: WorkloadTableLayoutMode = isMobile ? 'phone' : 'wide',
visibleColumnIds?: readonly string[],
manualWidths?: WorkloadColumnWidths,
): Pick<ColumnDef, 'width' | 'minWidth' | 'maxWidth'> | undefined => {
const column = GUEST_COLUMN_BY_ID.get(columnId);
if (!column) return undefined;
@ -567,7 +570,16 @@ const getGuestColumnSizing = (
maxWidth: column.maxWidth,
};
const override = getResponsiveColumnOverride(columnId, layoutMode, visibleColumnIds);
const pinned = manualWidths?.[columnId];
const manualOverride =
typeof pinned === 'number' && Number.isFinite(pinned) && pinned > 0
? (() => {
const width = `${Math.round(pinned)}px`;
return { width, minWidth: width, maxWidth: width };
})()
: undefined;
const override =
manualOverride ?? getResponsiveColumnOverride(columnId, layoutMode, visibleColumnIds);
if (override) {
if ('width' in override) sizing.width = override.width ?? undefined;
if ('minWidth' in override) sizing.minWidth = override.minWidth ?? undefined;
@ -582,8 +594,15 @@ export const getGuestColumnStyle = (
isMobile = false,
layoutMode?: WorkloadTableLayoutMode,
visibleColumnIds?: readonly string[],
manualWidths?: WorkloadColumnWidths,
): JSX.CSSProperties | undefined => {
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
const sizing = getGuestColumnSizing(
columnId,
isMobile,
layoutMode,
visibleColumnIds,
manualWidths,
);
if (!sizing) return undefined;
const style: JSX.CSSProperties = {};
@ -600,8 +619,15 @@ export const getGuestColumnWidthStyle = (
isMobile = false,
layoutMode?: WorkloadTableLayoutMode,
visibleColumnIds?: readonly string[],
manualWidths?: WorkloadColumnWidths,
): JSX.CSSProperties | undefined => {
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
const sizing = getGuestColumnSizing(
columnId,
isMobile,
layoutMode,
visibleColumnIds,
manualWidths,
);
if (!sizing?.width) return undefined;
return { width: sizing.width };
};
@ -627,7 +653,15 @@ export const getWorkloadTableLayoutModeForContainer = (width: number): WorkloadT
export const getWorkloadVisibleColumnsForLayout = (
columns: ColumnDef[],
layoutMode: WorkloadTableLayoutMode,
options?: { manualSizing?: boolean },
): ColumnDef[] => {
if (
options?.manualSizing &&
WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode] >= WORKLOAD_TABLE_LAYOUT_ORDER.tablet
) {
return [...columns];
}
const layoutRank = WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode];
return columns.filter((column) => {
const minimumLayout = WORKLOAD_COLUMN_MIN_LAYOUT[column.id] ?? 'wide';

View file

@ -32,6 +32,31 @@ import {
WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
type WorkloadTableMetricHistoryRange,
} from './workloadMetricHistoryModel';
import {
buildWorkloadColumnLayoutEntries,
parseWorkloadColumnLayoutParam,
resolveWorkloadColumnLayoutEntries,
serializeWorkloadColumnLayout,
toggleWorkloadColumnLayoutEntry,
workloadColumnLayoutIds,
workloadColumnLayoutWidths,
WORKLOAD_COLUMNS_URL_PARAM,
type WorkloadColumnLayoutEntry,
} from './workloadColumnLayoutUrl';
import {
hasWorkloadColumnWidths,
isWorkloadManualSizingSupported,
normalizeWorkloadColumnWidths,
pruneWorkloadColumnWidths,
seedWorkloadColumnWidthsFromDefaults,
snapshotWorkloadColumnWidths,
sumWorkloadColumnWidths,
WORKLOAD_COLUMN_FALLBACK_WIDTH,
withWorkloadColumnWidth,
withoutWorkloadColumnWidth,
workloadColumnWidthsStorageKey,
type WorkloadColumnWidths,
} from './workloadColumnWidths';
interface WorkloadsControlsStateOptions {
layoutWidth?: Accessor<number | null | undefined>;
@ -274,17 +299,151 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
);
const visibleColumns = columnVisibility.visibleColumns;
// User choices persist independently of the current width, but the live
// table only presents columns its responsive stage can keep readable. A
// selected wide-only column therefore disappears while space is tight and
// returns automatically when the table grows again.
const workloadTableVisibleColumns = createMemo(() =>
getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode()),
const [columnWidths, setColumnWidths] = usePersistentSignal<WorkloadColumnWidths>(
workloadColumnWidthsStorageKey(
STORAGE_KEYS.WORKLOADS_COLUMN_WIDTHS,
options.columnVisibilityStorageScope,
),
{},
{
serialize: (widths) => JSON.stringify(widths),
deserialize: (raw) => {
try {
return normalizeWorkloadColumnWidths(JSON.parse(raw));
} catch {
return {};
}
},
},
);
const [pendingColumnWidths, setPendingColumnWidths] = createSignal<WorkloadColumnWidths | null>(
null,
);
const manualColumnSizingSupported = createMemo(() =>
isWorkloadManualSizingSupported(workloadTableLayoutMode()),
);
const renderableColumnIds = createMemo(() => {
const relevant = relevantColumns();
return new Set(
workloadColumns
.filter((column) => !relevant || relevant.has(column.id))
.map((column) => column.id),
);
});
const urlColumnLayout = createMemo<WorkloadColumnLayoutEntry[]>(() => {
if (!routeStateEnabled()) return [];
return resolveWorkloadColumnLayoutEntries(
parseWorkloadColumnLayoutParam(
new URLSearchParams(activeRouteSearch()).get(WORKLOAD_COLUMNS_URL_PARAM),
),
renderableColumnIds(),
);
});
const hasUrlColumnLayout = createMemo(
() => manualColumnSizingSupported() && urlColumnLayout().length > 0,
);
const manualColumnSizingActive = createMemo(
() =>
manualColumnSizingSupported() &&
(pendingColumnWidths() !== null ||
hasUrlColumnLayout() ||
hasWorkloadColumnWidths(columnWidths())),
);
const activeColumnWidths = createMemo<WorkloadColumnWidths>(() => {
if (!manualColumnSizingActive()) return {};
const pending = pendingColumnWidths();
if (pending) return pending;
if (hasUrlColumnLayout()) return workloadColumnLayoutWidths(urlColumnLayout());
return columnWidths();
});
const workloadTableVisibleColumns = createMemo(() => {
if (hasUrlColumnLayout()) {
const byId = new Map(workloadColumns.map((column) => [column.id, column] as const));
return workloadColumnLayoutIds(urlColumnLayout())
.map((columnId) => byId.get(columnId))
.filter((column): column is (typeof workloadColumns)[number] => !!column);
}
return getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode(), {
manualSizing: manualColumnSizingActive(),
});
});
const workloadTableVisibleColumnIds = createMemo(() =>
workloadTableVisibleColumns().map((column) => column.id),
);
const totalColumns = createMemo(() => workloadTableVisibleColumns().length);
const workloadTableManualWidth = createMemo<number | null>(() =>
sumWorkloadColumnWidths(activeColumnWidths(), workloadTableVisibleColumnIds()),
);
const beginWorkloadColumnResize = (measuredWidths: Readonly<Record<string, number>>): void => {
if (!manualColumnSizingSupported()) return;
const measured = snapshotWorkloadColumnWidths(columnWidths(), measuredWidths);
setPendingColumnWidths(seedWorkloadColumnWidthsFromDefaults(measured, visibleColumns()));
};
const previewWorkloadColumnWidth = (columnId: string, width: number): void => {
setPendingColumnWidths((current) =>
current ? withWorkloadColumnWidth(current, columnId, width) : current,
);
};
const writeColumnLayoutParam = (entries: readonly WorkloadColumnLayoutEntry[] | null): void => {
updateSearchParam((params) => {
if (!entries || entries.length === 0) params.delete(WORKLOAD_COLUMNS_URL_PARAM);
else params.set(WORKLOAD_COLUMNS_URL_PARAM, serializeWorkloadColumnLayout(entries));
});
};
const publishColumnLayout = (
widths: WorkloadColumnWidths,
orderedIds: readonly string[],
): void => {
writeColumnLayoutParam(buildWorkloadColumnLayoutEntries(orderedIds, widths));
};
const commitWorkloadColumnResize = (): void => {
const pending = pendingColumnWidths();
if (!pending) return;
const renderedColumnIds = workloadTableVisibleColumnIds();
const committed = pruneWorkloadColumnWidths(pending, renderedColumnIds);
if (!hasUrlColumnLayout()) setColumnWidths(committed);
setPendingColumnWidths(null);
publishColumnLayout(committed, renderedColumnIds);
};
const cancelWorkloadColumnResize = (): void => {
setPendingColumnWidths(null);
};
const clearWorkloadColumnWidth = (columnId: string): void => {
const renderedColumnIds = workloadTableVisibleColumnIds();
const current = activeColumnWidths();
setPendingColumnWidths(null);
const linkOwnsLayout = hasUrlColumnLayout();
const without = withoutWorkloadColumnWidth(current, columnId);
if (!hasWorkloadColumnWidths(without)) {
if (!linkOwnsLayout) setColumnWidths({});
writeColumnLayoutParam(null);
return;
}
const column = workloadColumns.find((candidate) => candidate.id === columnId);
const restored = column ? seedWorkloadColumnWidthsFromDefaults(without, [column]) : without;
if (!linkOwnsLayout) setColumnWidths(restored);
publishColumnLayout(restored, renderedColumnIds);
};
const resetWorkloadColumnWidths = (): void => {
setPendingColumnWidths(null);
setColumnWidths({});
writeColumnLayoutParam(null);
};
const toggleWorkloadColumn = (columnId: string): void => {
if (!hasUrlColumnLayout()) {
columnVisibility.toggle(columnId);
return;
}
const column = workloadColumns.find((candidate) => candidate.id === columnId);
const defaultWidth =
column?.width && /^\d+(?:\.\d+)?px$/.test(column.width)
? Number.parseFloat(column.width)
: WORKLOAD_COLUMN_FALLBACK_WIDTH;
writeColumnLayoutParam(
toggleWorkloadColumnLayoutEntry(urlColumnLayout(), columnId, defaultWidth),
);
};
const handleSort = (key: WorkloadsSortKey) => {
if (sortKey() === key) {
@ -364,10 +523,16 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
availableColumns: getWorkloadVisibleColumnsForLayout(
columnVisibility.availableToggles(),
workloadTableLayoutMode(),
{ manualSizing: manualColumnSizingActive() },
),
isColumnHidden: columnVisibility.isHiddenByUser,
onColumnToggle: columnVisibility.toggle,
isColumnHidden: (columnId: string) =>
hasUrlColumnLayout()
? !workloadTableVisibleColumnIds().includes(columnId)
: columnVisibility.isHiddenByUser(columnId),
onColumnToggle: toggleWorkloadColumn,
onColumnReset: columnVisibility.resetToDefaults,
hasColumnWidthOverrides: manualColumnSizingActive(),
onColumnWidthsReset: resetWorkloadColumnWidths,
}));
return {
@ -396,6 +561,16 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadColumnWidths: activeColumnWidths,
workloadManualColumnSizing: manualColumnSizingActive,
workloadManualColumnSizingSupported: manualColumnSizingSupported,
workloadTableManualWidth,
beginWorkloadColumnResize,
previewWorkloadColumnWidth,
commitWorkloadColumnResize,
cancelWorkloadColumnResize,
clearWorkloadColumnWidth,
resetWorkloadColumnWidths,
setWorkloadMetricHistoryRange,
setWorkloadMetricDisplayMode,
} as const;

View file

@ -313,6 +313,16 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadColumnWidths,
workloadManualColumnSizing,
workloadManualColumnSizingSupported,
workloadTableManualWidth,
beginWorkloadColumnResize,
previewWorkloadColumnWidth,
commitWorkloadColumnResize,
cancelWorkloadColumnResize,
clearWorkloadColumnWidth,
resetWorkloadColumnWidths,
workloadMetricDisplayMode,
workloadMetricHistoryRange,
setWorkloadMetricDisplayMode,
@ -638,6 +648,16 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadColumnWidths,
workloadManualColumnSizing,
workloadManualColumnSizingSupported,
workloadTableManualWidth,
beginWorkloadColumnResize,
previewWorkloadColumnWidth,
commitWorkloadColumnResize,
cancelWorkloadColumnResize,
clearWorkloadColumnWidth,
resetWorkloadColumnWidths,
workloadNodeOptions,
workloads,
workloadInventoryIssues,

View file

@ -0,0 +1,111 @@
import { clampWorkloadColumnWidth, type WorkloadColumnWidths } from './workloadColumnWidths';
/**
* Shareable workload table layout, carried in the `cols` query parameter as
* `id:width` pairs — for example `cols=name:172,cpu:151,netIo:170`.
*
* The parameter is authoritative while it is present: the ids decide which
* columns render and in what order, and the widths pin them. That is what makes
* a copied URL reproduce the same table for whoever opens it, instead of
* landing on their own stored column preference. Removing the parameter returns
* the table to that stored preference, which is never overwritten by a link.
*/
export const WORKLOAD_COLUMNS_URL_PARAM = 'cols';
export interface WorkloadColumnLayoutEntry {
id: string;
width: number;
}
/** Guards against a hand-edited URL asking for an absurd number of columns. */
const MAX_LAYOUT_ENTRIES = 32;
const isSafeColumnId = (id: string): boolean => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
export const parseWorkloadColumnLayoutParam = (
raw: string | null | undefined,
): WorkloadColumnLayoutEntry[] => {
if (!raw) return [];
const entries: WorkloadColumnLayoutEntry[] = [];
const seen = new Set<string>();
for (const chunk of raw.split(',')) {
if (entries.length >= MAX_LAYOUT_ENTRIES) break;
const separator = chunk.lastIndexOf(':');
if (separator <= 0) continue;
const id = chunk.slice(0, separator).trim();
if (!isSafeColumnId(id) || seen.has(id)) continue;
const width = Number(chunk.slice(separator + 1).trim());
if (!Number.isFinite(width) || width <= 0) continue;
seen.add(id);
entries.push({ id, width: clampWorkloadColumnWidth(width) });
}
return entries;
};
export const serializeWorkloadColumnLayout = (
entries: readonly WorkloadColumnLayoutEntry[],
): string => entries.map((entry) => `${entry.id}:${Math.round(entry.width)}`).join(',');
/**
* Drops ids the current view cannot render — a stale link, a column retired by
* an upgrade, or a vSphere layout opened on Proxmox. Order follows the link.
*/
export const resolveWorkloadColumnLayoutEntries = (
entries: readonly WorkloadColumnLayoutEntry[],
renderableColumnIds: ReadonlySet<string>,
): WorkloadColumnLayoutEntry[] => entries.filter((entry) => renderableColumnIds.has(entry.id));
export const workloadColumnLayoutWidths = (
entries: readonly WorkloadColumnLayoutEntry[],
): WorkloadColumnWidths => {
const widths: Record<string, number> = {};
for (const entry of entries) widths[entry.id] = entry.width;
return widths;
};
export const workloadColumnLayoutIds = (entries: readonly WorkloadColumnLayoutEntry[]): string[] =>
entries.map((entry) => entry.id);
export const buildWorkloadColumnLayoutEntries = (
orderedColumnIds: readonly string[],
widths: WorkloadColumnWidths,
): WorkloadColumnLayoutEntry[] => {
const entries: WorkloadColumnLayoutEntry[] = [];
for (const id of orderedColumnIds) {
const width = widths[id];
if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) continue;
entries.push({ id, width: clampWorkloadColumnWidth(width) });
}
return entries;
};
/**
* Adds or removes one column while a link-driven layout is active. The stored
* column preference is deliberately left alone so that toggling inside somebody
* else's shared view does not rewrite the viewer's own defaults.
*/
export const toggleWorkloadColumnLayoutEntry = (
entries: readonly WorkloadColumnLayoutEntry[],
columnId: string,
defaultWidth: number,
): WorkloadColumnLayoutEntry[] => {
const id = columnId.trim();
if (!isSafeColumnId(id)) return [...entries];
const existing = entries.findIndex((entry) => entry.id === id);
if (existing >= 0) {
const next = entries.filter((entry) => entry.id !== id);
// Never let a link strip the table down to nothing.
return next.length > 0 ? next : [...entries];
}
if (entries.length >= MAX_LAYOUT_ENTRIES) return [...entries];
return [...entries, { id, width: clampWorkloadColumnWidth(defaultWidth) }];
};

View file

@ -0,0 +1,189 @@
import type { WorkloadTableLayoutMode } from './guestRowModel';
/**
* User-pinned workload column widths, in CSS pixels, keyed by column id.
*
* An empty map is the canonical "untouched" state: the responsive weights in
* `guestRowModel` own every width and the table keeps the maintained contract
* of fitting its container without horizontal scroll. A non-empty map means the
* operator has explicitly dragged a column edge and has therefore opted into
* manual sizing for the current scope.
*/
export type WorkloadColumnWidths = Readonly<Record<string, number>>;
export const WORKLOAD_COLUMN_MIN_WIDTH = 48;
export const WORKLOAD_COLUMN_MAX_WIDTH = 720;
/**
* Manual sizing is a pointer-driven desktop affordance. Touch layouts stay on
* the responsive weights so a width pinned on a wide monitor can never leak
* into a phone-sized render of the same scope.
*/
const MANUAL_SIZING_LAYOUT_MODES: ReadonlySet<WorkloadTableLayoutMode> = new Set([
'tablet',
'compact',
'wide',
]);
export const isWorkloadManualSizingSupported = (layoutMode: WorkloadTableLayoutMode): boolean =>
MANUAL_SIZING_LAYOUT_MODES.has(layoutMode);
/**
* Pointer travel, in CSS pixels, before a press on a resize handle counts as a
* resize rather than a click.
*
* Without a threshold a plain click on the handle would open and immediately
* commit a zero-delta drag, which pins every rendered width and silently
* switches the table into manual sizing — a mode change the operator never
* asked for.
*/
export const WORKLOAD_COLUMN_DRAG_ENGAGE_THRESHOLD = 3;
export const shouldEngageColumnResize = (deltaX: number, alreadyEngaged: boolean): boolean => {
if (alreadyEngaged) return true;
if (!Number.isFinite(deltaX)) return false;
return Math.abs(deltaX) >= WORKLOAD_COLUMN_DRAG_ENGAGE_THRESHOLD;
};
export const clampWorkloadColumnWidth = (width: number): number =>
Math.min(WORKLOAD_COLUMN_MAX_WIDTH, Math.max(WORKLOAD_COLUMN_MIN_WIDTH, Math.round(width)));
export const hasWorkloadColumnWidths = (widths: WorkloadColumnWidths | undefined): boolean =>
!!widths && Object.keys(widths).length > 0;
/**
* Accepts anything `localStorage` hands back and returns a map that the render
* path can trust. Unusable entries are dropped rather than throwing, so one bad
* key written by an older build cannot wedge the table.
*/
export const normalizeWorkloadColumnWidths = (value: unknown): WorkloadColumnWidths => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const normalized: Record<string, number> = {};
for (const [columnId, rawWidth] of Object.entries(value as Record<string, unknown>)) {
const id = columnId.trim();
if (!id) continue;
const width = typeof rawWidth === 'number' ? rawWidth : Number(rawWidth);
if (!Number.isFinite(width) || width <= 0) continue;
normalized[id] = clampWorkloadColumnWidth(width);
}
return normalized;
};
export const withWorkloadColumnWidth = (
widths: WorkloadColumnWidths,
columnId: string,
width: number,
): WorkloadColumnWidths => {
const id = columnId.trim();
if (!id || !Number.isFinite(width)) return widths;
return { ...widths, [id]: clampWorkloadColumnWidth(width) };
};
export const withoutWorkloadColumnWidth = (
widths: WorkloadColumnWidths,
columnId: string,
): WorkloadColumnWidths => {
if (!(columnId in widths)) return widths;
const next = { ...widths };
delete next[columnId];
return next;
};
/**
* Drops pins for columns that are no longer part of the visible set so a column
* the operator has since hidden cannot keep reserving width forever.
*/
export const pruneWorkloadColumnWidths = (
widths: WorkloadColumnWidths,
visibleColumnIds: readonly string[],
): WorkloadColumnWidths => {
const visible = new Set(visibleColumnIds);
const next: Record<string, number> = {};
for (const [columnId, width] of Object.entries(widths)) {
if (visible.has(columnId)) next[columnId] = width;
}
return Object.keys(next).length === Object.keys(widths).length ? widths : next;
};
/**
* `table-fixed` distributes leftover space across columns that have no explicit
* width, so pinning a single column would silently reflow every other one. The
* first drag therefore freezes the widths the operator can currently see, and
* only then applies their delta.
*/
export const snapshotWorkloadColumnWidths = (
widths: WorkloadColumnWidths,
measuredWidths: Readonly<Record<string, number>>,
): WorkloadColumnWidths => {
const next: Record<string, number> = { ...widths };
for (const [columnId, measured] of Object.entries(measuredWidths)) {
if (columnId in next) continue;
if (!Number.isFinite(measured) || measured <= 0) continue;
next[columnId] = clampWorkloadColumnWidth(measured);
}
return next;
};
/**
* Columns that only become visible once manual sizing engages were never
* measured, so they would otherwise arrive unpinned and let `table-fixed`
* redistribute the surplus across every column. Seeding them from their design
* width keeps the table exactly as wide as the sum of its columns.
*/
export const WORKLOAD_COLUMN_FALLBACK_WIDTH = 120;
export const parsePixelWidth = (value: string | undefined): number | undefined => {
if (!value) return undefined;
const match = /^(\d+(?:\.\d+)?)px$/.exec(value.trim());
if (!match) return undefined;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
};
export const seedWorkloadColumnWidthsFromDefaults = (
widths: WorkloadColumnWidths,
columns: readonly { id: string; width?: string; minWidth?: string }[],
): WorkloadColumnWidths => {
const next: Record<string, number> = { ...widths };
for (const column of columns) {
if (column.id in next) continue;
const preferred =
parsePixelWidth(column.width) ??
parsePixelWidth(column.minWidth) ??
WORKLOAD_COLUMN_FALLBACK_WIDTH;
next[column.id] = clampWorkloadColumnWidth(preferred);
}
return next;
};
/**
* Total width the table must claim so `table-layout: fixed` keeps applying.
*
* A table with `width: auto` silently drops out of fixed layout and falls back
* to auto layout, where a narrow column is re-expanded to its content minimum.
* Publishing the exact sum keeps the width definite, so every pin is honoured
* and the shell scrolls by precisely the overflow.
*
* Returns null when there is nothing pinned, which leaves the default
* percentage layout completely untouched.
*/
export const sumWorkloadColumnWidths = (
widths: WorkloadColumnWidths,
visibleColumnIds: readonly string[],
): number | null => {
if (!hasWorkloadColumnWidths(widths) || visibleColumnIds.length === 0) return null;
let total = 0;
for (const columnId of visibleColumnIds) {
const pinned = widths[columnId];
if (typeof pinned !== 'number' || !Number.isFinite(pinned) || pinned <= 0) return null;
total += pinned;
}
return total > 0 ? total : null;
};
export const workloadColumnWidthsStorageKey = (baseKey: string, scope?: string): string => {
const trimmed = scope?.trim();
return trimmed ? `${baseKey}:${trimmed}` : baseKey;
};

View file

@ -66,6 +66,8 @@ export interface WorkloadsFilterProps {
onColumnToggle: (id: string) => void;
onColumnReset?: () => void;
showReset?: boolean;
onColumnWidthsReset?: () => void;
hasColumnWidthOverrides?: boolean;
};
hostFilter?: WorkloadsToolbarFilterConfig;
platformFilter?: WorkloadsToolbarFilterConfig;

View file

@ -265,6 +265,17 @@ describe('post-update telemetry disclosure', () => {
});
describe('current candidate notification packet', () => {
it('keeps manual workload column sizing in the customer-facing release packet', () => {
const releaseNotes = readFileSync(
path.resolve(process.cwd(), '../docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md'),
'utf8',
);
expect(releaseNotes).toContain('Workload table columns can be resized');
expect(releaseNotes).toContain('keeps every selected column reachable');
expect(releaseNotes).toContain('shared through the page URL or reset');
});
it('keeps translation-ready webhook support in both release summaries', () => {
const releaseNotes = readFileSync(
path.resolve(process.cwd(), '../docs/releases/RELEASE_NOTES_v6.4.0-rc.3.md'),

View file

@ -114,6 +114,33 @@ describe('ColumnPicker', () => {
expect(onReset).toHaveBeenCalledOnce();
});
it('explains and clears an active manual-width layout independently of column visibility', async () => {
const onResetWidths = vi.fn();
render(() => (
<ColumnPicker
columns={[{ id: 'subject', label: 'Subject' }]}
isHidden={() => false}
onToggle={vi.fn()}
onResetWidths={onResetWidths}
hasManualWidths
/>
));
fireEvent.click(screen.getByRole('button', { name: /columns/i }));
expect(
await screen.findByText(
'Custom widths are active, so every enabled column stays visible and the table scrolls sideways.',
),
).toBeInTheDocument();
expect(
screen.queryByText('Enabled columns appear when table space allows.'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reset widths' }));
expect(onResetWidths).toHaveBeenCalledOnce();
});
it('closes when the user clicks outside the open picker', async () => {
render(() => (
<ColumnPicker

View file

@ -12,7 +12,9 @@ import {
COLUMN_PICKER_EMPTY_LABEL,
COLUMN_PICKER_PANEL_TITLE,
COLUMN_PICKER_RESPONSIVE_NOTE,
COLUMN_PICKER_MANUAL_WIDTH_NOTE,
COLUMN_PICKER_RESET_LABEL,
COLUMN_PICKER_RESET_WIDTHS_LABEL,
getColumnPickerOptionTextClass,
} from '@/components/shared/columnPickerModel';
import {
@ -29,17 +31,32 @@ export const ColumnPicker: Component<ColumnPickerProps> = (props) => {
<div class="border-b border-border-subtle px-3 py-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-base-content">{COLUMN_PICKER_PANEL_TITLE}</span>
<Show when={state.showReset()}>
<button
type="button"
onClick={state.handleResetClick}
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
>
{COLUMN_PICKER_RESET_LABEL}
</button>
</Show>
<div class="flex items-center gap-2">
<Show when={state.showResetWidths()}>
<button
type="button"
onClick={state.handleResetWidthsClick}
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
>
{COLUMN_PICKER_RESET_WIDTHS_LABEL}
</button>
</Show>
<Show when={state.showReset()}>
<button
type="button"
onClick={state.handleResetClick}
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
>
{COLUMN_PICKER_RESET_LABEL}
</button>
</Show>
</div>
</div>
<p class="mt-1 text-[10px] leading-4 text-muted">{COLUMN_PICKER_RESPONSIVE_NOTE}</p>
<p class="mt-1 text-[10px] leading-4 text-muted">
{state.showResetWidths()
? COLUMN_PICKER_MANUAL_WIDTH_NOTE
: COLUMN_PICKER_RESPONSIVE_NOTE}
</p>
</div>
<div class="max-h-64 overflow-y-auto py-1">

View file

@ -5,6 +5,9 @@ export const COLUMN_PICKER_BUTTON_TITLE = 'Choose which columns to display';
export const COLUMN_PICKER_PANEL_TITLE = 'Show Columns';
export const COLUMN_PICKER_RESPONSIVE_NOTE = 'Enabled columns appear when table space allows.';
export const COLUMN_PICKER_RESET_LABEL = 'Reset';
export const COLUMN_PICKER_RESET_WIDTHS_LABEL = 'Reset widths';
export const COLUMN_PICKER_MANUAL_WIDTH_NOTE =
'Custom widths are active, so every enabled column stays visible and the table scrolls sideways.';
export const COLUMN_PICKER_EMPTY_LABEL = 'No columns available to toggle';
export function getHiddenColumnCount(
@ -21,6 +24,13 @@ export function shouldShowColumnPickerReset(
return Boolean(onReset) && hiddenCount > 0;
}
export function shouldShowColumnPickerWidthReset(
onResetWidths: (() => void) | undefined,
hasManualWidths: boolean | undefined,
): boolean {
return Boolean(onResetWidths) && hasManualWidths === true;
}
export function getColumnPickerOptionTextClass(isChecked: boolean): string {
return `text-sm ${isChecked ? 'text-base-content' : 'text-muted'}`;
}

View file

@ -1,6 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { getHiddenColumnCount, shouldShowColumnPickerReset } from './columnPickerModel';
import {
getHiddenColumnCount,
shouldShowColumnPickerReset,
shouldShowColumnPickerWidthReset,
} from './columnPickerModel';
export interface ColumnPickerProps {
columns: ColumnDef[];
@ -8,6 +12,8 @@ export interface ColumnPickerProps {
onToggle: (id: string) => void;
onReset?: () => void;
showReset?: boolean;
onResetWidths?: () => void;
hasManualWidths?: boolean;
inline?: boolean;
}
@ -36,10 +42,15 @@ export function useColumnPickerState(props: ColumnPickerProps) {
const showReset = createMemo(
() => Boolean(props.showReset) || shouldShowColumnPickerReset(props.onReset, hiddenCount()),
);
const showResetWidths = createMemo(() =>
shouldShowColumnPickerWidthReset(props.onResetWidths, props.hasManualWidths),
);
return {
handleColumnToggle: (id: string) => props.onToggle(id),
handleResetClick: () => props.onReset?.(),
handleResetWidthsClick: () => props.onResetWidths?.(),
showResetWidths,
hiddenCount,
isColumnChecked: (id: string) => !props.isHidden(id),
isOpen,

View file

@ -1295,3 +1295,54 @@ body,
.animate-enter {
animation: enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
/* ---------------------------------------------------------------------------
Workload table manual column sizing
Default (no pinned widths): none of this applies and the responsive weights
keep the table inside its container, as they always have. Once an operator
drags a column edge the table opts into its pinned pixel widths, which means
it may exceed the shell — `.table-scroll-shell` already scrolls, so the
trade the operator asked for is simply allowed to happen.
--------------------------------------------------------------------------- */
/* The table publishes an explicit pixel width while manual sizing is on (see
`sumWorkloadColumnWidths`). `width: auto` would drop the table out of
`table-layout: fixed` and let a narrow column snap back to its content
minimum, so only the floor is expressed here. */
.table-scroll-shell > table.workload-table--manual-widths {
min-width: 100%;
}
.workload-col-resizer {
position: absolute;
top: 0;
right: -3px;
z-index: 1;
display: block;
width: 7px;
height: 100%;
cursor: col-resize;
/* Keeps the hit target from selecting header text mid-drag. */
user-select: none;
touch-action: none;
background-clip: content-box;
background-color: transparent;
border-right: 2px solid transparent;
transition: border-color 0.12s ease-in-out;
}
.workload-col-resizer:hover,
.workload-col-resizer:focus-visible {
border-right-color: var(--color-border-strong, currentColor);
outline: none;
}
.workload-col-resizer:focus-visible {
border-right-width: 3px;
}
@media (prefers-reduced-motion: reduce) {
.workload-col-resizer {
transition: none;
}
}

View file

@ -220,6 +220,7 @@ export const STORAGE_KEYS = {
// Column visibility
WORKLOADS_HIDDEN_COLUMNS: 'workloadsHiddenColumns',
WORKLOADS_COLUMN_WIDTHS: 'workloadsColumnWidths',
RECOVERY_HIDDEN_COLUMNS: 'backupsHiddenColumns',
STORAGE_HIDDEN_COLUMNS: 'storageHiddenColumns',