fix(ui): make Proxmox overview tables container-aware

Contract-Neutral: Responsive Proxmox Overview behavior and verification only; canonical subsystem ownership and public contracts are unchanged
This commit is contained in:
rcourtman
2026-08-04 01:14:22 +01:00
parent 46af83d5fd
commit 2bfab3610e
22 changed files with 415 additions and 92 deletions
+66 -27
View File
@@ -1,43 +1,82 @@
{
"version": 1,
"base_sha": "3a24d9694f3bb4062170149d02e0eb8241cf398b",
"verified_at": "2026-08-03T23:30:46Z",
"base_sha": "46af83d5fd65443f07a465446093aa805cd0b3e3",
"verified_at": "2026-08-04T00:12:38Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRowCells.tsx",
"frontend-modern/src/components/Workloads/guestRowModel.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/workloadsFilterModel.ts",
"frontend-modern/src/components/shared/columnPickerModel.ts",
"frontend-modern/src/components/shared/useColumnPickerState.ts",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
"frontend-modern/src/hooks/useObservedElementWidth.ts",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRowCells.tsx": "f34d05f6731bacafb07613ac982ba621865e0f3e1db7baae42cff7ac994917d8",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "6be3cc7e2958f3bd8ead420dac8467b11941b1037721e881a4f1e85e1f5c8cd1"
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "d420344cdb8a8277f9868a4e838099efa050a470ed4c22840aec0df0fd910542",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "a2e4911a8974cbb03db55d7a58fca37582974257aa7f88771eb1d6f98205d44f",
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "6852af254cf0fac698d0481bd228186b7237108e7345085e6d48fc2923493d83",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "4866bc3fd4ca22d73eade48321fefde6e6f2940c32ae3f98ade87a90c080254f",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "766ee0e188ad37e386049d37535f043d29fdf138fd0fc82c340c1817e9cafc14",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "86d9a97ecb10502e6baf7ca68a884db1fdd74afed1240e6b4ff5b35ee76e2d11",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "bd418028233fc52e361a0428d859b77e8fc77f9f00dfa8baa1449577a506eb17",
"frontend-modern/src/components/shared/columnPickerModel.ts": "a8c34404ae8da4030a126d2188c8f3486f6a699fbdce12531e4e15e97a806175",
"frontend-modern/src/components/shared/useColumnPickerState.ts": "260020f896719b4fd1bed8d2511b3a47028753f822c3769a2fe7bd5a4be05d3e",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "bc7c1146577abd439ea21490c8d0ad38d4efbf030c9ebbd2d6ed0e225a2ed7bb",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "35f4e5511dba2782d3f2d32a84c470094f9290635953275991eda65d9dff16d6",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "8d70932ef914db8870c96f0f77a2fab2d39bb70081918b82bd5bbf0a0b9db437",
"frontend-modern/src/hooks/useObservedElementWidth.ts": "cfb04bc6dde18060344b26d6c663fc4d3f147882323dc1cb1091ea3f03ac56f5",
"frontend-modern/src/index.css": "7b2f6c80b1e8a75849b0633b41be1c74d6740d263982aa5f3f365a6eb733dc17"
},
"routes": [
"/proxmox"
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
{ "width": 375, "height": 812 },
{ "width": 480, "height": 812 },
{ "width": 640, "height": 900 },
{ "width": 768, "height": 900 },
{ "width": 800, "height": 900 },
{ "width": 900, "height": 900 },
{ "width": 1024, "height": 900 },
{ "width": 1280, "height": 900 },
{ "width": 1440, "height": 900 },
{ "width": 1536, "height": 900 },
{ "width": 1680, "height": 900 },
{ "width": 1920, "height": 900 },
{ "width": 2560, "height": 900 }
],
"states": [
"Proxmox Overview grouped workload table against the live 7655 backend with 34 guests across delly, minipc and pi",
"Avail column populated for probed guests and blank for unprobed guests such as iventoy, ollama and tails-anon",
"Backup column with all 34 rows in the fresh state rendering a bare green shield with no drawn age and no pill chrome",
"View preferences popover open with the Columns section expanded and the new Avail toggle listed",
"Avail column hidden through the new toggle and then restored to the original hidden set"
"Live Proxmox Overview against the local backend with the Nodes table and grouped VM/LXC workload inventory visible",
"Automatic container-aware column stages from four essential host columns at narrow widths through the full ten-column host inventory at wide widths",
"Automatic workload stages from five essential columns through the wide workload metrics and context columns",
"Overview-only wide data workspace using the available monitor width while retaining normal text and control sizes",
"All optional workload columns explicitly selected with a readable 1510px table floor and intentional horizontal scrolling",
"Column picker with the renamed Reset action still visible after every optional column is selected",
"Grouped and List workload modes at compact and wide resolutions",
"Bars and Trends metric display modes",
"Pulse Assistant open beside Overview at desktop widths",
"Very narrow 375px and 480px layouts retaining readable essential columns through deliberate table-local scrolling"
],
"interactions": [
"Signed in at 1440x900 and read the Proxmox Overview table through DOM assertions on the availability cells and the backup status badges",
"Confirmed availability cells render bare latency such as 3ms, 1ms and 5ms with no trailing freshness suffix",
"Confirmed all 34 fresh backup badges render empty text, carry the green shield colour, and have no rounded-full pill chrome, while their aria-label still reports the backup age",
"Opened View then Columns, clicked the Avail checkbox off, and confirmed the availability header and every availability cell were removed and the preference persisted to workloadsHiddenColumns",
"Clicked Avail back on and confirmed workloadsHiddenColumns returned to its original value of aiContext, os, ip",
"Resized to 390x844 and confirmed both columns render unchanged with no horizontal document overflow",
"Read console messages filtered to errors and found none across both viewports"
"Returned from Storage to /proxmox/overview before implementation and kept all subsequent product testing scoped to Overview",
"Resized the real in-app browser through 375, 480, 640, 768, 800, 900, 1024, 1280, 1440, 1536, 1680, 1920 and 2560 pixel widths",
"Asserted both table wrapper overflow and every header's clientWidth versus scrollWidth at each final matrix width; found zero default overflow and zero clipped headers from 640px through 2560px",
"Visually inspected screenshots at 900x812 and 1440x900, confirming readable labels, metric bars, host context and workload density",
"Opened View and Columns, selected IP, AI Context, Tags, OS, Net I/O and Disk I/O, and confirmed all 15 columns render at a readable 1510px floor rather than being squeezed",
"Resized the all-column table from 1536px to 1024px and confirmed the 1510px floor, readable 243px first cell and table-local 572px horizontal overflow",
"Confirmed Reset remains available with zero hidden columns, then restored the responsive defaults and verified overflow returned to zero",
"Hid Tags at 1680px, resized to 1024px and back, and confirmed the hidden choice persisted; showed Tags, resized to 1024px, and confirmed the explicit choice stayed visible with a readable 996px floor before resetting",
"Switched to List mode across 1024, 1280, 1536, 1680, 1920 and 2560px, confirmed zero overflow, then restored Grouped mode",
"Switched from Bars to Trends at 900px, confirmed both tables retained zero overflow, then restored Bars",
"Opened Pulse Assistant and tested 1280, 1536, 1920 and 2560px; both tables adapted to the reduced measured container with zero overflow and no clipped headers",
"Closed Pulse Assistant, closed the View menu, restored default columns, restored the normal browser viewport and left /proxmox/overview open"
]
}
@@ -101,6 +101,13 @@ describe('App platform navigation admission', () => {
});
describe('App architecture', () => {
it('limits the wider shell treatment to marked data surfaces', () => {
expect(appStylesSource).toContain(
'.pulse-shell:has(.pulse-wide-data-surface):not(.pulse-shell--full-width)',
);
expect(appStylesSource).toContain('--pulse-shell-max-width: min(97vw, 1920px)');
});
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
expect(appSource).toContain(
"import { AppLayout, sessionHasSettingsAccess } from '@/AppLayout';",
@@ -354,6 +354,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
isHidden={visibility().isColumnHidden}
onToggle={visibility().onColumnToggle}
onReset={visibility().onColumnReset}
showReset={visibility().showReset}
/>
</div>
)}
@@ -214,6 +214,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
workloadMemoryDisplayBasis={state.workloadMemoryDisplayBasis}
workloadMetricHistory={state.workloadMetricHistory}
workloadTableLayoutMode={state.workloadTableLayoutMode}
workloadTableMinimumWidth={state.workloadTableMinimumWidth}
workloadTableVisibleColumnIds={state.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={state.workloadTableVisibleColumns}
/>
@@ -58,6 +58,7 @@ type WorkloadsTableProps = Pick<
| 'workloadMemoryDisplayBasis'
| 'workloadMetricHistory'
| 'workloadTableLayoutMode'
| 'workloadTableMinimumWidth'
| 'workloadTableVisibleColumnIds'
| 'workloadTableVisibleColumns'
>;
@@ -74,6 +75,11 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
<Table
wrapperRef={props.setTableWrapperRef}
class={`workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}`}
style={
props.workloadTableMinimumWidth()
? { 'min-width': `${props.workloadTableMinimumWidth()}px` }
: undefined
}
>
<colgroup>
<For each={props.workloadTableVisibleColumns()}>
@@ -136,6 +136,8 @@ import {
getGuestColumnStyle,
getGuestColumnWidthStyle,
getWorkloadTableLayoutMode,
getWorkloadTableLayoutModeForContainer,
getWorkloadTableReadableMinWidth,
getWorkloadVisibleColumnsForLayout,
type WorkloadIOEmphasis,
} from '../guestRowModel';
@@ -1142,6 +1144,25 @@ describe('GUEST_COLUMNS', () => {
expect(getWorkloadTableLayoutMode(1536)).toBe('wide');
});
it('maps workload table layout modes to the actual table container', () => {
expect(getWorkloadTableLayoutModeForContainer(719)).toBe('mobile');
expect(getWorkloadTableLayoutModeForContainer(720)).toBe('tablet');
expect(getWorkloadTableLayoutModeForContainer(899)).toBe('tablet');
expect(getWorkloadTableLayoutModeForContainer(900)).toBe('compact');
expect(getWorkloadTableLayoutModeForContainer(1439)).toBe('compact');
expect(getWorkloadTableLayoutModeForContainer(1440)).toBe('wide');
});
it('adds a readable width floor only when an explicit column exceeds the layout', () => {
const columns = GUEST_COLUMNS.filter((column) =>
['name', 'cpu', 'memory', 'netIo'].includes(column.id),
);
expect(getWorkloadTableReadableMinWidth(columns, 'compact', new Set(['netIo']))).toBe(630);
expect(getWorkloadTableReadableMinWidth(columns, 'compact', new Set(['cpu']))).toBeNull();
expect(getWorkloadTableReadableMinWidth(columns, 'wide', new Set(['netIo']))).toBeNull();
});
it('keeps CPU and memory fixed while allowing disk to be platform-scoped', () => {
const nonToggleable = GUEST_COLUMNS.filter((c) => !c.toggleable);
const ids = nonToggleable.map((c) => c.id);
@@ -69,12 +69,13 @@ describe('useWorkloadsControlsState', () => {
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
expect(menu.isColumnHidden('netIo')).toBe(false);
expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo');
expect(state.workloadsFilterColumnVisibility().showReset).toBe(true);
// Second toggle unpins it; the column returns to its layout default
// rather than becoming user-hidden on wide viewports too.
// Second toggle hides it everywhere, so the same explicit choice has
// the same meaning regardless of the current responsive stage.
menu.onColumnToggle('netIo');
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo');
expect(state.columnVisibility.hiddenColumns()).toContain('netIo');
// Layout-visible columns keep the plain hide/show semantics.
menu.onColumnToggle('backup');
@@ -87,6 +88,36 @@ describe('useWorkloadsControlsState', () => {
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
menu.onColumnReset();
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
expect(state.workloadsFilterColumnVisibility().showReset).toBe(false);
} finally {
dispose();
}
});
});
it('keeps a user-shown column pinned when its measured container narrows', () => {
createRoot((dispose) => {
try {
const [showFilters, setShowFilters] = createSignal(false);
const [layoutWidth, setLayoutWidth] = createSignal(1500);
const state = useWorkloadsControlsState({
viewMode: () => 'all' as ViewMode,
showFilters,
setShowFilters,
layoutWidth,
});
const menu = state.workloadsFilterColumnVisibility();
expect(state.workloadTableLayoutMode()).toBe('wide');
menu.onColumnToggle('netIo');
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
menu.onColumnToggle('netIo');
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
setLayoutWidth(1000);
expect(state.workloadTableLayoutMode()).toBe('compact');
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
expect(state.workloadTableMinimumWidth()).toBeGreaterThan(1000);
} finally {
dispose();
}
@@ -22,6 +22,9 @@ export const WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH = 900;
// a user explicitly pins (see forcedColumnIds); the breakpoint default must
// fit, so wide waits until the shell can actually hold the full set.
export const WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH = 1536;
export const WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH = 720;
export const WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH = 900;
export const WORKLOAD_TABLE_CONTAINER_WIDE_WIDTH = 1440;
const WORKLOAD_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
mobile: 0,
@@ -548,6 +551,13 @@ export const getWorkloadTableLayoutMode = (width: number): WorkloadTableLayoutMo
return 'wide';
};
export const getWorkloadTableLayoutModeForContainer = (width: number): WorkloadTableLayoutMode => {
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH) return 'mobile';
if (width < WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH) return 'tablet';
if (width < WORKLOAD_TABLE_CONTAINER_WIDE_WIDTH) return 'compact';
return 'wide';
};
export const getWorkloadVisibleColumnsForLayout = (
columns: ColumnDef[],
layoutMode: WorkloadTableLayoutMode,
@@ -559,6 +569,31 @@ export const getWorkloadVisibleColumnsForLayout = (
});
};
const parsePixelWidth = (value: string | undefined): number => {
const match = value?.trim().match(/^(\d+(?:\.\d+)?)px$/);
return match ? Number(match[1]) : 0;
};
export const getWorkloadTableReadableMinWidth = (
columns: readonly ColumnDef[],
layoutMode: WorkloadTableLayoutMode,
forcedColumnIds: ReadonlySet<string>,
): number | null => {
const layoutRank = WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode];
const hasForcedOverflowColumn = columns.some((column) => {
if (!forcedColumnIds.has(column.id)) return false;
const minimumLayout = WORKLOAD_COLUMN_MIN_LAYOUT[column.id] ?? 'wide';
return WORKLOAD_TABLE_LAYOUT_ORDER[minimumLayout] > layoutRank;
});
if (!hasForcedOverflowColumn) return null;
const width = columns.reduce(
(total, column) => total + parsePixelWidth(column.minWidth ?? column.width),
0,
);
return width > 0 ? Math.ceil(width) : null;
};
export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
all: new Set([
'name',
@@ -12,6 +12,8 @@ import type { ViewMode } from '@/types/workloads';
import {
GUEST_COLUMNS,
VIEW_MODE_COLUMNS,
getWorkloadTableLayoutModeForContainer,
getWorkloadTableReadableMinWidth,
getWorkloadTableLayoutMode,
getWorkloadVisibleColumnsForLayout,
} from './guestRowModel';
@@ -32,6 +34,7 @@ import {
} from './workloadMetricHistoryModel';
interface WorkloadsControlsStateOptions {
layoutWidth?: Accessor<number | null | undefined>;
forcedGroupingMode?: WorkloadsGroupingMode;
defaultSortKey?: WorkloadsSortKey;
statusModeStorageScope?: string;
@@ -84,7 +87,12 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
const location = useLocation();
const navigate = useNavigate();
const breakpoint = useBreakpoint();
const workloadTableLayoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const workloadTableLayoutMode = createMemo(() => {
const measuredWidth = options.layoutWidth?.();
return typeof measuredWidth === 'number' && measuredWidth > 0
? getWorkloadTableLayoutModeForContainer(measuredWidth)
: getWorkloadTableLayoutMode(breakpoint.width());
});
const isMobile = createMemo(() => workloadTableLayoutMode() === 'mobile');
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
@@ -215,12 +223,10 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
['aiContext'],
);
// Columns the user explicitly pinned into view while the responsive layout
// had them gated off. Without this, the columns menu offered toggles (IP,
// Tags, Net I/O, ...) that did nothing below the wide breakpoint, and
// sub-1440px viewports had no path to data v5 reached via horizontal
// scroll. Explicit intent beats the breakpoint default; the table wrapper
// scrolls horizontally when the pinned columns no longer fit.
// Columns the user explicitly chose to show. Keeping that intent separate
// from the responsive defaults makes the choice stable across resizing:
// automatic columns may disappear as space contracts, while an explicit
// choice remains readable via horizontal scrolling when necessary.
const [forcedColumnIds, setForcedColumnIds] = usePersistentSignal<string[]>(
`${columnStorageKey}:forced`,
[],
@@ -264,6 +270,13 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
const workloadTableVisibleColumnIds = createMemo(() =>
workloadTableVisibleColumns().map((column) => column.id),
);
const workloadTableMinimumWidth = createMemo(() =>
getWorkloadTableReadableMinWidth(
workloadTableVisibleColumns(),
workloadTableLayoutMode(),
forcedColumnIdSet(),
),
);
const totalColumns = createMemo(() => workloadTableVisibleColumns().length);
const handleSort = (key: WorkloadsSortKey) => {
@@ -340,28 +353,21 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
}
};
// Menu checkbox state mirrors what the table actually renders: a column the
// layout gates off reads as hidden even when the user never hid it. Toggling
// such a column pins it into view (or unpins it) instead of flipping a
// user-hidden flag the user cannot see the effect of.
// Menu checkbox state mirrors what the table actually renders. Showing a
// column records explicit intent; hiding it removes that pin and applies the
// same preference at every responsive stage.
const isColumnHiddenForMenu = (id: string): boolean =>
columnVisibility.isHiddenByUser(id) ||
(layoutHiddenColumnIds().has(id) && !forcedColumnIdSet().has(id));
const handleColumnToggle = (id: string): void => {
if (!layoutHiddenColumnIds().has(id)) {
if (!isColumnHiddenForMenu(id)) {
setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id));
columnVisibility.toggle(id);
columnVisibility.hide(id);
return;
}
if (forcedColumnIdSet().has(id)) {
setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id));
return;
}
setForcedColumnIds([...forcedColumnIds(), id]);
if (columnVisibility.isHiddenByUser(id)) {
columnVisibility.toggle(id);
}
columnVisibility.show(id);
if (!forcedColumnIdSet().has(id)) setForcedColumnIds([...forcedColumnIds(), id]);
};
const handleColumnReset = (): void => {
@@ -374,6 +380,7 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
isColumnHidden: isColumnHiddenForMenu,
onColumnToggle: handleColumnToggle,
onColumnReset: handleColumnReset,
showReset: forcedColumnIds().length > 0,
}));
return {
@@ -402,6 +409,7 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadTableMinimumWidth,
setWorkloadMetricHistoryRange,
setWorkloadMetricDisplayMode,
} as const;
@@ -66,6 +66,7 @@ export interface WorkloadsSurfaceProps {
vms: VM[];
containers: Container[];
nodes: Node[];
layoutWidth?: Accessor<number | null | undefined>;
useWorkloads?: boolean;
forcedPlatform?: string;
forcedViewMode?: ViewMode;
@@ -271,6 +272,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadTableMinimumWidth,
workloadMetricDisplayMode,
workloadMetricHistoryRange,
setWorkloadMetricDisplayMode,
@@ -286,6 +288,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
columnVisibilityStorageScope: props.columnVisibilityStorageScope,
additionalDefaultHiddenColumnIds: props.additionalDefaultHiddenColumnIds,
columnLabelOverrides: props.columnLabelOverrides,
layoutWidth: props.layoutWidth,
setShowFilters,
showFilters,
viewMode: effectiveViewMode,
@@ -585,6 +588,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadTableMinimumWidth,
workloadNodeOptions,
workloads,
workloadInventoryIssues,
@@ -50,6 +50,7 @@ export interface WorkloadsFilterProps {
isColumnHidden: (id: string) => boolean;
onColumnToggle: (id: string) => void;
onColumnReset?: () => void;
showReset?: boolean;
};
hostFilter?: WorkloadsToolbarFilterConfig;
platformFilter?: WorkloadsToolbarFilterConfig;
@@ -95,6 +95,24 @@ describe('ColumnPicker', () => {
expect(screen.getByText('2 hidden')).toBeInTheDocument();
});
it('keeps reset available for explicit overrides even when no column is hidden', async () => {
const onReset = vi.fn();
render(() => (
<ColumnPicker
columns={[{ id: 'subject', label: 'Subject' }]}
isHidden={() => false}
onToggle={vi.fn()}
onReset={onReset}
showReset
/>
));
fireEvent.click(screen.getByRole('button', { name: /columns/i }));
fireEvent.click(await screen.findByRole('button', { name: 'Reset' }));
expect(onReset).toHaveBeenCalledOnce();
});
it('closes when the user clicks outside the open picker', async () => {
render(() => (
<ColumnPicker
@@ -3,7 +3,7 @@ import type { ColumnDef } from '@/hooks/useColumnVisibility';
export const COLUMN_PICKER_BUTTON_LABEL = 'Columns';
export const COLUMN_PICKER_BUTTON_TITLE = 'Choose which columns to display';
export const COLUMN_PICKER_PANEL_TITLE = 'Show Columns';
export const COLUMN_PICKER_RESET_LABEL = 'Show all';
export const COLUMN_PICKER_RESET_LABEL = 'Reset';
export const COLUMN_PICKER_EMPTY_LABEL = 'No columns available to toggle';
export function getHiddenColumnCount(
@@ -7,6 +7,7 @@ export interface ColumnPickerProps {
isHidden: (id: string) => boolean;
onToggle: (id: string) => void;
onReset?: () => void;
showReset?: boolean;
inline?: boolean;
}
@@ -32,7 +33,9 @@ export function useColumnPickerState(props: ColumnPickerProps) {
});
const hiddenCount = createMemo(() => getHiddenColumnCount(props.columns, props.isHidden));
const showReset = createMemo(() => shouldShowColumnPickerReset(props.onReset, hiddenCount()));
const showReset = createMemo(
() => Boolean(props.showReset) || shouldShowColumnPickerReset(props.onReset, hiddenCount()),
);
return {
handleColumnToggle: (id: string) => props.onToggle(id),
@@ -257,7 +257,7 @@ describe('platform overview layout guardrails', () => {
it('keeps provider overview pages in the parent-table plus child-inventory stack', () => {
for (const source of overviewSurfaceSources) {
expect(source).toMatch(/<div[^>]*class="space-y-4"/);
expect(source).toMatch(/<div[^>]*class="[^"]*\bspace-y-4\b[^"]*"/);
expect(source).toContain('<PlatformSectionTabs');
expect(source).toContain('<PlatformTableLoadingState');
expect(source).toContain('PlatformTableEmptyState');
@@ -55,6 +55,7 @@ import {
} from './proxmoxPageModel';
import {
getProxmoxHostColumnWidthStyle,
getProxmoxHostTableLayoutModeForContainer,
getProxmoxHostTableMinWidthClass,
getProxmoxHostVisibleColumnsForLayout,
type ProxmoxHostTableColumn,
@@ -186,6 +187,7 @@ export const ProxmoxNodesTable: Component<{
guests: Resource[];
metricDisplayMode?: Accessor<WorkloadsMetricDisplayMode>;
metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;
layoutWidth?: Accessor<number | null | undefined>;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
@@ -195,7 +197,12 @@ export const ProxmoxNodesTable: Component<{
const alertsActivation = useAlertsActivation();
const alertsEnabled = alertsActivation.detectionEnabled;
const [selectedNodeId, setSelectedNodeId] = createSignal<string | null>(null);
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const layoutMode = createMemo(() => {
const measuredWidth = props.layoutWidth?.();
return typeof measuredWidth === 'number' && measuredWidth > 0
? getProxmoxHostTableLayoutModeForContainer(measuredWidth)
: getWorkloadTableLayoutMode(breakpoint.width());
});
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
@@ -29,6 +29,7 @@ import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOut
import { PlatformOutdatedSensorSetupNotice } from '@/features/platformPage/PlatformOutdatedSensorSetupNotice';
import { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
PlatformErrorState,
@@ -272,10 +273,12 @@ interface ProxmoxOverviewProps {
function ProxmoxOverview(props: ProxmoxOverviewProps) {
const currentModel = createMemo(() => props.model?.() ?? EMPTY_PROXMOX_PAGE_MODEL);
const overviewWidth = useObservedElementWidth();
const workloadsState = useWorkloadsState({
vms: [],
containers: [],
nodes: [],
layoutWidth: overviewWidth.width,
useWorkloads: true,
forcedPlatform: PROXMOX_PLATFORM_FILTER,
excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES,
@@ -309,7 +312,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
);
return (
<div class="space-y-4">
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface space-y-4">
<Show when={showSharedFilterToolbar()}>
<div data-summary-clear-ignore>
<WorkloadsFilter
@@ -355,6 +358,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
guests={currentModel().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
layoutWidth={overviewWidth.width}
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
getProxmoxHostColumnWidthStyle,
getProxmoxHostTableMinWidthClass,
getProxmoxHostTableLayoutModeForContainer,
getProxmoxHostVisibleColumnsForLayout,
} from '../proxmoxHostTableModel';
@@ -12,30 +13,44 @@ describe('proxmoxHostTableModel', () => {
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']);
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-full');
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-[36rem]');
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '40%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '20%' });
});
it('adds temperature and guest counts before slower-changing metadata on tablet', () => {
it('adds operational context before inventory metadata on tablet', () => {
expect(getProxmoxHostVisibleColumnsForLayout('tablet').map((column) => column.id)).toEqual([
'node',
'cpu',
'memory',
'disk',
'temp',
'vms',
'cts',
'uptime',
'cluster',
]);
expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-full');
expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-[50rem]');
});
it('keeps the full host inventory table on compact and wide layouts', () => {
it('adds guest counts on compact and reserves version for wide layouts', () => {
const compactIds = getProxmoxHostVisibleColumnsForLayout('compact').map((column) => column.id);
expect(compactIds).toEqual([
'node',
'cpu',
'memory',
'disk',
'temp',
'uptime',
'vms',
'cts',
'cluster',
]);
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '10%',
});
expect(getProxmoxHostVisibleColumnsForLayout('wide').map((column) => column.id)).toEqual([
'node',
'version',
'cpu',
@@ -47,16 +62,22 @@ describe('proxmoxHostTableModel', () => {
'cts',
'cluster',
]);
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '10.7527%',
});
});
it('chooses host columns from available container width', () => {
expect(getProxmoxHostTableLayoutModeForContainer(799)).toBe('mobile');
expect(getProxmoxHostTableLayoutModeForContainer(800)).toBe('tablet');
expect(getProxmoxHostTableLayoutModeForContainer(1039)).toBe('tablet');
expect(getProxmoxHostTableLayoutModeForContainer(1040)).toBe('compact');
expect(getProxmoxHostTableLayoutModeForContainer(1319)).toBe('compact');
expect(getProxmoxHostTableLayoutModeForContainer(1320)).toBe('wide');
});
it('fits the container on compact and reserves the fixed floor only on wide', () => {
// The compact band (900-1440px) covers most laptops. Forcing a 1240px floor
// The compact band covers most laptops. Forcing a 1240px floor
// there pushed the rightmost column behind a horizontal scroll, so compact
// now fits its container; only wide keeps the fixed-width floor.
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-full');
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-[64rem]');
expect(getProxmoxHostTableMinWidthClass('wide')).toBe('min-w-[1240px]');
});
});
@@ -26,11 +26,11 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
memory: 'mobile',
disk: 'mobile',
temp: 'tablet',
vms: 'tablet',
cts: 'tablet',
version: 'compact',
uptime: 'compact',
cluster: 'compact',
uptime: 'tablet',
cluster: 'tablet',
vms: 'compact',
cts: 'compact',
version: 'wide',
};
// CPU, memory, and disk render the same kind of usage bar, so they share
@@ -40,16 +40,16 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
// columns (version pill, uptime, temp gauge, vms/cts badges, cluster
// pill) take only what they need.
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
node: 17,
version: 6,
uptime: 6,
node: 18,
version: 7,
uptime: 7,
cpu: 13,
memory: 13,
disk: 13,
temp: 5,
temp: 6,
vms: 5,
cts: 5,
cluster: 10,
cluster: 13,
};
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
@@ -63,15 +63,34 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
disk: 20,
},
tablet: {
node: 28,
cpu: 18,
memory: 18,
disk: 18,
temp: 6,
vms: 6,
cts: 6,
node: 22,
cpu: 15,
memory: 15,
disk: 15,
temp: 8,
uptime: 11,
cluster: 14,
},
compact: HOST_COLUMN_DESKTOP_WIDTHS,
compact: {
node: 20,
cpu: 15,
memory: 15,
disk: 15,
temp: 7,
uptime: 8,
vms: 5,
cts: 5,
cluster: 10,
},
};
export const getProxmoxHostTableLayoutModeForContainer = (
width: number,
): WorkloadTableLayoutMode => {
if (!Number.isFinite(width) || width < 800) return 'mobile';
if (width < 1040) return 'tablet';
if (width < 1320) return 'compact';
return 'wide';
};
// Column order follows the canonical recommended ordering documented in
@@ -112,12 +131,15 @@ export const getProxmoxHostColumnWidthStyle = (
return getPlatformTableWeightedColumnWidthStyle(columnId, weights, visibleColumnIds);
};
// Only the `wide` layout (>= 1440px viewport) reserves a fixed 1240px floor so
// the metric bars hit their canonical 140px width. The `compact` band spans
// 900-1440px, which covers most laptops; forcing 1240px there pushed the
// rightmost column (Cluster) behind a horizontal scroll that is easy to miss.
// Because the table is `table-fixed` with percentage column widths, `min-w-full`
// fits the container exactly and the bars scale down gracefully instead.
// Each layout has an explicit readable floor. The shared platform shell
// otherwise applies its generic 48rem minimum even to the four-column mobile
// layout, causing needless overflow around tablet widths. Wider modes retain
// progressively larger floors so metric bars and labels never collapse.
export const getProxmoxHostTableMinWidthClass = (
layoutMode: WorkloadTableLayoutMode,
): 'min-w-full' | 'min-w-[1240px]' => (layoutMode === 'wide' ? 'min-w-[1240px]' : 'min-w-full');
): 'min-w-[36rem]' | 'min-w-[50rem]' | 'min-w-[64rem]' | 'min-w-[1240px]' => {
if (layoutMode === 'mobile') return 'min-w-[36rem]';
if (layoutMode === 'tablet') return 'min-w-[50rem]';
if (layoutMode === 'compact') return 'min-w-[64rem]';
return 'min-w-[1240px]';
};
@@ -0,0 +1,54 @@
import { cleanup, render, waitFor } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useObservedElementWidth } from '../useObservedElementWidth';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('useObservedElementWidth', () => {
it('tracks the rendered container and disconnects its observer', async () => {
let measuredWidth = 640;
let resizeCallback: ResizeObserverCallback | undefined;
const disconnect = vi.fn();
class ResizeObserverStub {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = vi.fn();
disconnect = () => disconnect();
unobserve = vi.fn();
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
let width = () => null as number | null;
const Harness = () => {
const observed = useObservedElementWidth();
width = observed.width;
return (
<div
ref={(element) => {
Object.defineProperty(element, 'clientWidth', {
configurable: true,
get: () => measuredWidth,
});
observed.setElement(element);
}}
/>
);
};
const { unmount } = render(() => <Harness />);
await waitFor(() => expect(width()).toBe(640));
measuredWidth = 880;
resizeCallback?.([], {} as ResizeObserver);
await waitFor(() => expect(width()).toBe(880));
unmount();
expect(disconnect).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,34 @@
import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
const normalizeElementWidth = (width: number): number | null =>
Number.isFinite(width) && width > 0 ? Math.round(width) : null;
export interface ObservedElementWidth {
setElement: (element: HTMLElement) => void;
width: Accessor<number | null>;
}
/**
* Tracks the usable inline width of a rendered surface. Responsive tables use
* this instead of the viewport so side panels and constrained layouts select
* columns that actually fit their container.
*/
export const useObservedElementWidth = (): ObservedElementWidth => {
const [element, setElement] = createSignal<HTMLElement>();
const [width, setWidth] = createSignal<number | null>(null);
createEffect(() => {
const target = element();
if (!target) return;
const update = () => setWidth(normalizeElementWidth(target.clientWidth));
update();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(update);
observer.observe(target);
onCleanup(() => observer.disconnect());
});
return { setElement, width };
};
+6
View File
@@ -214,6 +214,12 @@
--pulse-shell-padding-inline: clamp(1rem, 2vw, 3rem);
}
/* Dense overview tables benefit from a larger workspace on wide monitors,
while ordinary reading and form surfaces keep the default 1560px cap. */
.pulse-shell:has(.pulse-wide-data-surface):not(.pulse-shell--full-width) {
--pulse-shell-max-width: min(97vw, 1920px);
}
.pulse-panel {
padding: var(--pulse-panel-padding);
}