Fix Proxmox LXC workload columns

This commit is contained in:
rcourtman 2026-08-19 20:22:55 +01:00
parent e2e8b72baa
commit 202f3b2f4c
9 changed files with 122 additions and 31 deletions

View file

@ -252,7 +252,11 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
`useWorkloadsState` must apply them before dedupe, filter-option
derivation, summary statistics, and row rendering so a platform overview
that demotes nested workloads does not carry hidden rows through downstream
table state.
table state. The shared workload column profile must consume those same
exclusions. When the public combined `container` view has only system or
application containers remaining, it must resolve to that subtype's
canonical column set instead of exposing empty runtime-specific columns;
page-local column suppression and duplicate platform schemas are forbidden.
Responsive workload columns are part of the same table hot-path and
usability contract. The active width stage must filter both the rendered
column set and the column picker's available choices; a remembered user

View file

@ -1,25 +1,23 @@
{
"version": 1,
"base_sha": "e7de6602f7d43052950fb72d9591ecf154090f10",
"verified_at": "2026-08-19T19:07:24Z",
"base_sha": "e2e8b72baa56b60107fb8bc694a27394cd76c8c6",
"verified_at": "2026-08-19T19:21:10Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/FilterButtonGroup.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/index.css"
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/FilterButtonGroup.tsx": "4f7c115ad13ad614f5ebb3f73d2c0f7561342e2a6b58b2bde36cf11d828d54ca",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "c946582423cf0f95e3e8f92d676c175a51c2f474be70ed383f882b7ef30e8973",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "01ea9d0ec46b0cb97870aa145b17fdd1fcc4fd8b3576ffdd21dfd2f4ad50e5c7",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "66fd5ef686cd052d37fa59ceacdae524a9274fd12e1f22f4c2be0e1ca80f5ce4",
"frontend-modern/src/index.css": "d8fe7b2c49b50003ab493cd37e37bbdd5c637764c1efd23fa20b966550c7ba5f"
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "ace9b2dfb177850527b1719792098c33fd7fcf9fd57bb2ac7aacde03b17aae16",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "f5f5b87a6ebf6d4ad71bfb60bfeeb796b29a7e9a989d111128997efc16c0f8a7",
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "00d3ce0ed568326f1a6b96c2d9e1efeba84f8abd3df795bc19ad498e6ec55702",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "9921577a647d6d617ab2b66df7c6b8f35dc93a72ae4273a869e01743a92c414f"
},
"routes": [
"/proxmox/overview",
"/vmware/overview"
"/proxmox/overview?type=container"
],
"viewports": [
{
@ -32,18 +30,17 @@
}
],
"states": [
"Proxmox desktop default bounded node preview with eight rows, visible workload controls, and the workload table in the initial viewport",
"Proxmox desktop expanded 32-node estate and restored eight-row collapsed preview",
"Proxmox phone default bounded node preview with four rows and the reveal control visible",
"Proxmox phone expanded 32-node estate and restored four-row collapsed preview without horizontal overflow",
"Proxmox inventory totals shown and hidden across filter counts, Nodes topology, and the node reveal label",
"Proxmox and vSphere 12px labels with 11px counts sharing identical 16px line boxes and baselines"
"Proxmox desktop All workload view with the shared mixed-workload column profile",
"Proxmox desktop LXCs workload view narrowed to the system-container Name, ID, CPU, Mem, Disk, Uptime, and Backup columns",
"Proxmox desktop LXC Columns disclosure without Runtime, Image, Context, or Update choices",
"Reloaded Proxmox LXC route preserving the selected type and narrowed column profile",
"Proxmox narrow LXC view with the responsive Name, CPU, Mem, Disk, and Age columns",
"Desktop and narrow document and table scroll widths equal to their client widths"
],
"interactions": [
"Expanded and collapsed Show all 32 nodes on desktop and phone",
"Switched Proxmox workload status from All to Attention and observed the adjacent workload table change from 150 rendered rows to 21",
"Hid and restored Inventory totals from the View menu and confirmed the node reveal label stopped exposing the total while hidden",
"Compared the final shared inline count geometry on Proxmox and vSphere",
"Cleared the temporary Proxmox status filter and left /proxmox/overview at 1440x900"
"Switched from All to LXCs and confirmed the column profile narrowed without changing the public type=container URL",
"Opened View and its Columns disclosure and confirmed application-container fields were absent",
"Dismissed the open View menu with Escape and confirmed focus returned to the View trigger",
"Reloaded the selected LXC route and resized between 1440x900 and 390x844 while retaining the corrected profile"
]
}

View file

@ -138,6 +138,7 @@ import {
getWorkloadTableLayoutMode,
getWorkloadTableLayoutModeForContainer,
getWorkloadVisibleColumnsForLayout,
resolveWorkloadColumnViewMode,
type WorkloadIOEmphasis,
} from '../guestRowModel';
@ -1355,6 +1356,16 @@ describe('VIEW_MODE_COLUMNS', () => {
}
}
});
it('narrows the combined container column profile when a subtype is excluded', () => {
expect(resolveWorkloadColumnViewMode('container', ['app-container'])).toBe('system-container');
expect(resolveWorkloadColumnViewMode('container', ['system-container'])).toBe('app-container');
expect(resolveWorkloadColumnViewMode('container')).toBe('container');
expect(resolveWorkloadColumnViewMode('container', ['system-container', 'app-container'])).toBe(
'container',
);
expect(resolveWorkloadColumnViewMode('vm', ['app-container'])).toBe('vm');
});
});
describe('getOutlierEmphasis (via I/O column rendering)', () => {

View file

@ -1078,7 +1078,7 @@ describe('Workloads performance contract', () => {
nodes={[]}
useWorkloads
forcedPlatform="proxmox-pve"
forcedViewMode="all"
forcedViewMode="container"
excludedWorkloadTypes={['app-container']}
showNestedExcludedWorkloads
/>
@ -1094,6 +1094,12 @@ describe('Workloads performance contract', () => {
).toBeNull();
expect(screen.queryByTestId('nested-workload-context-row')).toBeNull();
expect(screen.getByTestId('guest-row-nested-cue-pve-lxc')).toHaveTextContent('Docker 1');
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Backup' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'Runtime' })).toBeNull();
expect(screen.queryByRole('columnheader', { name: 'Image' })).toBeNull();
expect(screen.queryByRole('columnheader', { name: 'Context' })).toBeNull();
expect(screen.queryByRole('columnheader', { name: 'Update' })).toBeNull();
fireEvent.click(screen.getByTestId('guest-row-toggle-pve-lxc'));
expect(await screen.findByTestId('guest-drawer')).toBeInTheDocument();

View file

@ -238,6 +238,52 @@ describe('useWorkloadsControlsState', () => {
});
});
it('uses the remaining container subtype for columns when a platform excludes the other subtype', () => {
createRoot((dispose) => {
try {
const [showFilters, setShowFilters] = createSignal(false);
const proxmoxState = useWorkloadsControlsState({
viewMode: () => 'container' as ViewMode,
excludedWorkloadTypes: ['app-container'],
showFilters,
setShowFilters,
});
const visibleColumnIds = proxmoxState.visibleColumns().map((column) => column.id);
expect(visibleColumnIds).toContain('vmid');
expect(visibleColumnIds).toContain('backup');
expect(visibleColumnIds).not.toContain('runtime');
expect(visibleColumnIds).not.toContain('image');
expect(visibleColumnIds).not.toContain('context');
expect(visibleColumnIds).not.toContain('update');
} finally {
dispose();
}
});
createRoot((dispose) => {
try {
const [showFilters, setShowFilters] = createSignal(false);
const appContainerState = useWorkloadsControlsState({
viewMode: () => 'container' as ViewMode,
excludedWorkloadTypes: ['system-container'],
showFilters,
setShowFilters,
});
const visibleColumnIds = appContainerState.visibleColumns().map((column) => column.id);
expect(visibleColumnIds).toContain('runtime');
expect(visibleColumnIds).toContain('image');
expect(visibleColumnIds).toContain('context');
expect(visibleColumnIds).toContain('update');
expect(visibleColumnIds).not.toContain('vmid');
expect(visibleColumnIds).not.toContain('backup');
} finally {
dispose();
}
});
});
// The Backup column reads only `resource.proxmox.lastBackup`, so scopes that
// opt into hiding it (vSphere) rendered "None" on every row for anyone whose
// saved preference predates that default. The one-time migration retires that

View file

@ -7,7 +7,7 @@ import {
getPlatformTableWeightedColumnWidthStyle,
PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT,
} from '@/features/platformPage/sharedPlatformPage';
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import type { WorkloadGuest, WorkloadType, ViewMode } from '@/types/workloads';
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
import type {
WorkloadMetricHistoryReader,
@ -705,3 +705,22 @@ export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
]),
pod: new Set(['name', 'cpu', 'memory', 'image', 'namespace', 'context', 'aiContext']),
};
/**
* The public `container` view intentionally covers both system containers and
* application containers. Platform-owned surfaces can exclude either subtype;
* in that case the column profile must narrow with the same canonical boundary
* instead of exposing fields that every remaining row renders empty.
*/
export const resolveWorkloadColumnViewMode = (
viewMode: ViewMode,
excludedWorkloadTypes: readonly WorkloadType[] = [],
): ViewMode => {
if (viewMode !== 'container') return viewMode;
const includesSystemContainers = !excludedWorkloadTypes.includes('system-container');
const includesAppContainers = !excludedWorkloadTypes.includes('app-container');
if (includesSystemContainers === includesAppContainers) return viewMode;
return includesSystemContainers ? 'system-container' : 'app-container';
};

View file

@ -7,7 +7,7 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { blurFocusedTypeToSearch } from '@/hooks/useTypeToSearch';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { aiChatStore } from '@/stores/aiChat';
import type { ViewMode } from '@/types/workloads';
import type { ViewMode, WorkloadType } from '@/types/workloads';
import {
GUEST_COLUMNS,
@ -15,6 +15,7 @@ import {
getWorkloadTableLayoutModeForContainer,
getWorkloadTableLayoutMode,
getWorkloadVisibleColumnsForLayout,
resolveWorkloadColumnViewMode,
} from './guestRowModel';
import {
DEFAULT_WORKLOADS_SORT_DIRECTION,
@ -52,6 +53,7 @@ interface WorkloadsControlsStateOptions {
// empty cell on every row. Omitted means "assume data exists".
hasAvailabilityData?: Accessor<boolean>;
columnLabelOverrides?: Partial<Record<string, string>>;
excludedWorkloadTypes?: readonly WorkloadType[];
setShowFilters: (value: boolean | ((current: boolean) => boolean)) => void;
showFilters: Accessor<boolean>;
viewMode: Accessor<ViewMode>;
@ -209,7 +211,11 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
);
const relevantColumns = createMemo(() => {
const base = VIEW_MODE_COLUMNS[options.viewMode()];
const columnViewMode = resolveWorkloadColumnViewMode(
options.viewMode(),
options.excludedWorkloadTypes,
);
const base = VIEW_MODE_COLUMNS[columnViewMode];
const dropNode = effectiveGroupingMode() === 'grouped';
const dropAvailability = !hasAvailabilityData();
if (!base) {

View file

@ -163,6 +163,7 @@ export function useWorkloadsDerivedState(options: WorkloadsWorkloadDerivedStateO
});
const totalStats = createMemo(() => computeWorkloadStats(filteredGuests()));
const inventoryStats = createMemo(() => computeWorkloadStats(options.allGuests()));
const workloadIOEmphasis = createMemo(() => computeWorkloadIOEmphasis(filteredGuests()));
return {
@ -171,6 +172,7 @@ export function useWorkloadsDerivedState(options: WorkloadsWorkloadDerivedStateO
groupedGuests,
groupedWindowing,
guestParentNodeMap,
inventoryStats,
nodeByInstance,
topSpacerHeight,
totalStats,

View file

@ -27,7 +27,6 @@ import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import {
buildWorkloadSummaryGroupScopeMap,
computeWorkloadStats,
createWorkloadSortComparator,
filterWorkloads,
type FilterWorkloadsParams,
@ -314,6 +313,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
onMetricHistoryRangeChange: props.onMetricHistoryRangeChange,
columnVisibilityStorageScope: props.columnVisibilityStorageScope,
additionalDefaultHiddenColumnIds: props.additionalDefaultHiddenColumnIds,
excludedWorkloadTypes: props.excludedWorkloadTypes,
hasAvailabilityData,
columnLabelOverrides: props.columnLabelOverrides,
layoutWidth: props.layoutWidth,
@ -494,6 +494,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
groupedGuests,
groupedWindowing,
guestParentNodeMap,
inventoryStats,
nodeByInstance,
topSpacerHeight,
totalStats,
@ -513,7 +514,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
tableBodyRef,
groupLabelBadges,
});
const inventoryStats = createMemo(() => computeWorkloadStats(allGuests()));
return {
activeAlerts,