From 2bfab3610e499abafdb416ff4eb1b57a26f01bca Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 4 Aug 2026 01:14:22 +0100 Subject: [PATCH] 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 --- frontend-modern/browser-verification.json | 93 +++++++++++++------ .../src/__tests__/App.architecture.test.ts | 7 ++ .../components/Workloads/WorkloadsFilter.tsx | 1 + .../components/Workloads/WorkloadsSurface.tsx | 1 + .../components/Workloads/WorkloadsTable.tsx | 6 ++ .../Workloads/__tests__/GuestRow.test.tsx | 21 +++++ .../useWorkloadsControlsState.test.ts | 37 +++++++- .../components/Workloads/guestRowModel.tsx | 35 +++++++ .../Workloads/useWorkloadsControlsState.ts | 50 +++++----- .../components/Workloads/useWorkloadsState.ts | 4 + .../Workloads/workloadsFilterModel.ts | 1 + .../components/shared/ColumnPicker.test.tsx | 18 ++++ .../components/shared/columnPickerModel.ts | 2 +- .../components/shared/useColumnPickerState.ts | 5 +- .../platformOverviewLayout.guardrails.test.ts | 2 +- .../features/proxmox/ProxmoxNodesTable.tsx | 9 +- .../features/proxmox/ProxmoxPageSurface.tsx | 6 +- .../__tests__/proxmoxHostTableModel.test.ts | 43 ++++++--- .../features/proxmox/proxmoxHostTableModel.ts | 72 +++++++++----- .../useObservedElementWidth.test.tsx | 54 +++++++++++ .../src/hooks/useObservedElementWidth.ts | 34 +++++++ frontend-modern/src/index.css | 6 ++ 22 files changed, 415 insertions(+), 92 deletions(-) create mode 100644 frontend-modern/src/hooks/__tests__/useObservedElementWidth.test.tsx create mode 100644 frontend-modern/src/hooks/useObservedElementWidth.ts diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7d2acc9e0..0d7388d8c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 2586b45be..2922bd44e 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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';", diff --git a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx index f2328337e..ded0d9e0d 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx @@ -354,6 +354,7 @@ export const WorkloadsFilter: Component = (props) => { isHidden={visibility().isColumnHidden} onToggle={visibility().onColumnToggle} onReset={visibility().onColumnReset} + showReset={visibility().showReset} /> )} diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index d05c2b6a1..5bf39f6cc 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -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} /> diff --git a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx index 1dca71aed..1fbf4dfa0 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx @@ -58,6 +58,7 @@ type WorkloadsTableProps = Pick< | 'workloadMemoryDisplayBasis' | 'workloadMetricHistory' | 'workloadTableLayoutMode' + | 'workloadTableMinimumWidth' | 'workloadTableVisibleColumnIds' | 'workloadTableVisibleColumns' >; @@ -74,6 +75,11 @@ export function WorkloadsTable(props: WorkloadsTableProps) { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 3526ef3e4..ebe7cc810 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -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); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts index 5dbbd2e19..c5f7a92f5 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts @@ -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(); } diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 05d6aa687..6990fbbe3 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -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 = { 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, +): 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 | null> = { all: new Set([ 'name', diff --git a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts index 557202e32..07bd68941 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts @@ -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; 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( `${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; diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 686c0f125..e761edf78 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -66,6 +66,7 @@ export interface WorkloadsSurfaceProps { vms: VM[]; containers: Container[]; nodes: Node[]; + layoutWidth?: Accessor; 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, diff --git a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts index db095300d..3270d0b46 100644 --- a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts +++ b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts @@ -50,6 +50,7 @@ export interface WorkloadsFilterProps { isColumnHidden: (id: string) => boolean; onColumnToggle: (id: string) => void; onColumnReset?: () => void; + showReset?: boolean; }; hostFilter?: WorkloadsToolbarFilterConfig; platformFilter?: WorkloadsToolbarFilterConfig; diff --git a/frontend-modern/src/components/shared/ColumnPicker.test.tsx b/frontend-modern/src/components/shared/ColumnPicker.test.tsx index e3ed510ae..b368e9a9e 100644 --- a/frontend-modern/src/components/shared/ColumnPicker.test.tsx +++ b/frontend-modern/src/components/shared/ColumnPicker.test.tsx @@ -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(() => ( + 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(() => ( 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), diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 177a942b5..0757b8b49 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -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(/]*class="space-y-4"/); + expect(source).toMatch(/]*class="[^"]*\bspace-y-4\b[^"]*"/); expect(source).toContain('; metricHistoryRange?: Accessor; + layoutWidth?: Accessor; 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(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'; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 56a49e9c2..fbe12667f 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -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 ( -
+
} emptyTitle="No Proxmox VE nodes" emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory." diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 160e19090..db66571dc 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -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]'); }); }); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index a49b566f7..7fd52450f 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -26,11 +26,11 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { - 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]'; +}; diff --git a/frontend-modern/src/hooks/__tests__/useObservedElementWidth.test.tsx b/frontend-modern/src/hooks/__tests__/useObservedElementWidth.test.tsx new file mode 100644 index 000000000..5ad71a60b --- /dev/null +++ b/frontend-modern/src/hooks/__tests__/useObservedElementWidth.test.tsx @@ -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 ( +
{ + Object.defineProperty(element, 'clientWidth', { + configurable: true, + get: () => measuredWidth, + }); + observed.setElement(element); + }} + /> + ); + }; + + const { unmount } = render(() => ); + await waitFor(() => expect(width()).toBe(640)); + + measuredWidth = 880; + resizeCallback?.([], {} as ResizeObserver); + await waitFor(() => expect(width()).toBe(880)); + + unmount(); + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend-modern/src/hooks/useObservedElementWidth.ts b/frontend-modern/src/hooks/useObservedElementWidth.ts new file mode 100644 index 000000000..b4b0f9223 --- /dev/null +++ b/frontend-modern/src/hooks/useObservedElementWidth.ts @@ -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; +} + +/** + * 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(); + const [width, setWidth] = createSignal(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 }; +}; diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index a960a34c8..12b071e30 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -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); }