From 6b78feba8a45de8bf671b753f027abeb4dc517eb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Aug 2026 17:27:13 +0100 Subject: [PATCH] fix(ui): stop rendering two uninformative workload columns Follow-up audit after the vSphere Backup fix, sweeping every platform page and tab for columns that say the same thing on every row. Avail (both Proxmox and vSphere, default-visible): the cell renders nothing at all until an availability check is linked to that workload. Availability checks are opt-in per resource, so any install without one showed an empty column under an "Avail" header on every row. Gate it on live data via a new `hasAvailabilityData` accessor rather than on a stored preference: writing a hidden preference would overwrite the user's own choice the first time a probe appeared. The column now returns by itself when data arrives, and the accessor reads the unfiltered guest set so narrowing the table by search or status never makes it vanish. Tags (vSphere, default-visible): every VM rendered six dots carrying the identical tag set, because `internal/vmware/provider.go` fills `Resource.Tags` with fixed provenance strings (`vmware`, `vsphere`, `vm`, `source:vcenter`, `connection:`, `power:`) rather than reading vCenter's tag API. Five are constant across an estate and the sixth restates the power state the status filter already owns, so filtering on any of them selects everything. Default-hide it on the vSphere scope, with the same one-time migration used for `backup` so existing preferences are retired too. Proxmox keeps the column since its tags are genuine per-guest labels. The Tags hide is a stopgap, not the canonical fix. vCenter does expose a real tag/category system; the adapter simply does not read it. The tags stay in the payload because search and facet counts consume them, and the hide should be removed once the adapter ingests real vCenter tags. Also checked and left alone: AI Context on vSphere reports the backend's own `unsupported` discovery state as "N/A", which is honest and already default-hidden. Docker, Kubernetes, TrueNAS, Machines and every Proxmox and vSphere sub-tab had no single-value columns. Contract-Neutral: Contract delta staged in performance-and-scalability.md Extension Point 17 (vSphere tags stopgap + the hasAvailabilityData gate). Residual demand is a Workloads hot-path perf proof; inapplicable because this only changes which columns are offered at render time and adds no per-row or per-frame work, so no hot-path proof file legitimately changes. --- .../subsystems/performance-and-scalability.md | 17 ++++ frontend-modern/browser-verification.json | 95 ++++++------------- .../useWorkloadsControlsState.test.ts | 95 +++++++++++++++++++ .../Workloads/useWorkloadsControlsState.ts | 39 ++++++-- .../components/Workloads/useWorkloadsState.ts | 11 +++ .../src/features/vmware/VmwarePageSurface.tsx | 13 ++- 6 files changed, 197 insertions(+), 73 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index e3e213cbb..a55cfd706 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -624,6 +624,23 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. control on that page. The migration only fires where the id is already in that scope's effective default-hidden set, and it records a marker so a column the user deliberately restores is never re-hidden. + vSphere additionally default-hides `tags`. The vSphere adapter does not + read vCenter's tag/category API; `internal/vmware/provider.go` fills + `Resource.Tags` with fixed provenance strings, so the column repeats the + same values on every row and filtering on any of them selects the whole + estate. The tags stay in the payload because search and facet counts + consume them. This hide is a stopgap and must be removed once the adapter + ingests real vCenter tags, not treated as the settled contract. + Column visibility that depends on whether data exists at all is data-gated + rather than preference-gated. `availability` is the current case: the cell + renders nothing until an availability check is linked to that workload, so + `useWorkloadsState` passes a `hasAvailabilityData` accessor and + `useWorkloadsControlsState` drops the column from `relevantColumns` while + it is false. That path must never write a hidden preference, because doing + so would overwrite the user's own choice the moment the first check + appears; the column returns by itself when data arrives. The accessor + reads the unfiltered guest set so narrowing the table by search or status + never removes the column. 18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. High-frequency Type and Status filters stay in that catalog but render as inline compact segmented controls (`inline: true`), while longer or dynamic scope filters continue through the "+ Filter" menu and chip popovers. The Add filter control inherits FilterBar's compact accessible-only label by default instead of paying for a page-local labelled-field shell. Durable presentation controls pass only their panel content through `FilterBar.viewOptions`; the shared FilterBar owns the single View trigger and popover. Contextual actions use `leadingControls`, while frequently changed analytical orientation such as the active trend range uses `trailingControls`. Workload filter option semantics stay workload-owned, but FilterBar chip presentation is frontend-primitives-owned: status and runtime leading dots diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 74a9731a0..8ff2bfc44 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,83 +1,50 @@ { "version": 1, - "base_sha": "dbd353427bafc1a58fc0a999132dcce5976082bd", - "verified_at": "2026-08-06T16:26:44Z", + "base_sha": "324a6458d5b4972ea47a56ca54ca997af0e05fc0", + "verified_at": "2026-08-06T16:26:28Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx", - "frontend-modern/src/components/Settings/SystemLogsPanel.tsx", - "frontend-modern/src/components/shared/filterButtonGroupModel.ts" + "frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx" ], "content_sha256": { - "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx": "c57ed6aaa87b36cec0568dd3bedd16fad4f75862f81e18cc1a24d87e4b9734c8", - "frontend-modern/src/components/Settings/SystemLogsPanel.tsx": "783b93652b2e17450ca4cf3205bd1706b1aad59c0757d1332042682f76439384", - "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "33408c8af3eece8604647d6cb7133ef675b50adbc983c0451200f35e99bf7075" + "frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "e27f9b5fb3f5cc6e65a7e8d465befe45e0f0c8f0479e8b4a43f53b02ffac9d6f", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "14897fd2ae8c79a26b85ac21d42fb86270092ca7c3a084cc087b160f2c2311e5", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "9c37c036b26f32af1f4a973580a221fd35b7c36482ec6efd109e03d6de6b6427" }, "routes": [ - "/proxmox/overview", - "/proxmox/storage?tab=disks&source=proxmox-all", - "/docker/overview", - "/docker/images", - "/kubernetes/nodes", - "/kubernetes/workloads", - "/truenas/overview", - "/truenas/storage", - "/vmware/overview", - "/vmware/storage", - "/standalone/machines", - "/standalone/availability", - "/alerts/overview", - "/alerts/history", - "/alerts/thresholds", - "/alerts/notifications", - "/alerts/schedule", - "/actions", - "/patrol", - "/settings/infrastructure", - "/settings/security/api", - "/settings/security-overview", - "/settings/security-data-handling", - "/settings/security-auth", - "/settings/security-sso", - "/settings/security-roles", - "/settings/security-users", - "/settings/security-audit", - "/settings/security-webhooks", - "/settings/support/diagnostics", - "/settings/support/reporting", - "/settings/support/logs" + "/vmware", + "/proxmox?status=all" ], "viewports": [ + { + "width": 2400, + "height": 1300 + }, + { + "width": 1280, + "height": 800 + }, { "width": 390, - "height": 900 - }, - { - "width": 768, - "height": 900 - }, - { - "width": 1440, - "height": 900 + "height": 844 } ], "states": [ - "Mock-data platform tables at the 768px column-priority transition across Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines, and Availability", - "Security, support, Alerts, Actions, and Patrol pages at 390px, including touch-target and document-overflow scans", - "Diagnostics and System Logs phone controls before and after the touch-target correction", - "Compact platform filters expanded at phone width and restored to dense tablet sizing", - "Docker search-tips popover at phone and tablet widths", - "Expanded Machines resource details at tablet width", - "Shared wide-shell alignment across Proxmox, Alerts, Actions, Patrol, and Settings" + "Baseline before the change, vSphere VM table with every column enabled: Avail rendered an empty cell on all 18 rows and Tags rendered six identical dots on all 18, matching the single provenance tag set the adapter emits", + "vSphere VM table for a user with no saved preference: header is Name, ID, CPU, Mem, Disk, Uptime, Net I/O, Disk I/O with Avail and Tags both absent", + "vSphere VM table for a user whose saved preference predates the Tags default: the one-time migration adds tags to the hidden list and records it in the default-hidden-applied marker", + "vSphere column picker still offers Tags, Backup, AI Context, OS, IP, Disk, Uptime and both I/O columns, and no longer offers Avail while nothing is probed", + "Proxmox workload table unaffected: Tags still visible with genuine per-guest counts varying 3 to 7 dots per row, Backup still visible, stored hidden list unchanged at aiContext/os/ip", + "Proxmox at 390px and 1280px: no Avail column, no horizontal body overflow, 16 rows render", + "vSphere at 390px: narrow stage renders Name, CPU, Mem across 18 rows with no horizontal body overflow" ], "interactions": [ - "Scanned 18 Settings, Alerts, Actions, and Patrol routes at 390px for horizontal document overflow, off-screen content, and visible primary controls below a 44px phone target", - "Found and corrected 40–42px Diagnostics and System Logs controls, then re-measured the live pages with no remaining undersized controls", - "Expanded Docker filters and confirmed the short All chip grows from 35px to 44px on phones while returning to the compact 35px width at 768px", - "Confirmed Patrol's only compact visual exception retains its extended 44px hit area and that its filter targets are fully touch-sized", - "Measured prioritized visible columns and table shells on 12 platform routes at 768px; every table fit its container without horizontal scrolling", - "Opened Machines resource details at 768px and confirmed the inline drawer remains contained without page-level overflow", - "Opened Docker search tips and measured fully visible 288px popover bounds at both 390px and 768px", - "Measured identical 1362px main shells for Proxmox, Alerts, Actions, Patrol, and Settings at 1440px" + "Opened the View preferences popover and the Columns multiselect on /vmware to read the offered toggles", + "Cleared the workloadsHiddenColumns scope keys to reproduce a first-time user, then reloaded", + "Seeded a pre-migration workloadsHiddenColumns:vmware-vms preference without tags and reloaded to confirm the one-time hide fires", + "Navigated between /vmware and /proxmox?status=all at each viewport to compare Avail and Tags across scopes", + "Queried /api/resources to confirm all 18 vSphere VMs carry the identical provenance tag set while Proxmox guests carry distinct per-guest tags" ] } diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts index 5720ef5ae..b405bec71 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts @@ -313,4 +313,99 @@ describe('useWorkloadsControlsState', () => { } }); }); + + // vSphere tags are adapter provenance strings that repeat on every row, so + // the scope hides the column; Proxmox tags are real per-guest labels. + it('retires a stale Tags preference only on scopes that default-hide it', async () => { + localStorage.setItem( + 'workloadsHiddenColumns:vmware-vms', + JSON.stringify(['aiContext', 'os', 'ip']), + ); + + const disposeVmware = createRoot((dispose) => { + const [showFilters, setShowFilters] = createSignal(false); + const state = useWorkloadsControlsState({ + viewMode: () => 'all' as ViewMode, + showFilters, + setShowFilters, + columnVisibilityStorageScope: 'vmware-vms', + additionalDefaultHiddenColumnIds: ['backup', 'tags'], + }); + expect(state.columnVisibility.hiddenColumns()).toContain('tags'); + expect(state.visibleColumns().map((column) => column.id)).not.toContain('tags'); + // Still offered in the column picker for anyone who wants it back. + expect(state.columnVisibility.availableToggles().map((column) => column.id)).toContain( + 'tags', + ); + return dispose; + }); + + await Promise.resolve(); + disposeVmware(); + + localStorage.setItem('workloadsHiddenColumns', JSON.stringify(['aiContext', 'os', 'ip'])); + createRoot((dispose) => { + try { + const [showFilters, setShowFilters] = createSignal(false); + const state = useWorkloadsControlsState({ + viewMode: () => 'all' as ViewMode, + showFilters, + setShowFilters, + }); + expect(state.columnVisibility.hiddenColumns()).not.toContain('tags'); + expect(state.visibleColumns().map((column) => column.id)).toContain('tags'); + } finally { + dispose(); + } + }); + }); + + // The Availability cell is empty until a check is linked to that workload, + // so the column is gated on live data rather than on a stored preference. + it('drops the Availability column while nothing is probed and restores it once something is', () => { + createRoot((dispose) => { + try { + const [showFilters, setShowFilters] = createSignal(false); + const [probed, setProbed] = createSignal(false); + const state = useWorkloadsControlsState({ + viewMode: () => 'all' as ViewMode, + showFilters, + setShowFilters, + hasAvailabilityData: probed, + }); + + expect(state.visibleColumns().map((column) => column.id)).not.toContain('availability'); + expect(state.columnVisibility.availableToggles().map((column) => column.id)).not.toContain( + 'availability', + ); + // Gating the column must not write a hidden preference, otherwise the + // user's own choice would be overwritten the moment a probe appears. + expect(state.columnVisibility.hiddenColumns()).not.toContain('availability'); + + setProbed(true); + expect(state.visibleColumns().map((column) => column.id)).toContain('availability'); + expect(state.columnVisibility.availableToggles().map((column) => column.id)).toContain( + 'availability', + ); + } finally { + dispose(); + } + }); + }); + + it('keeps the Availability column when no probe accessor is supplied', () => { + createRoot((dispose) => { + try { + const [showFilters, setShowFilters] = createSignal(false); + const state = useWorkloadsControlsState({ + viewMode: () => 'all' as ViewMode, + showFilters, + setShowFilters, + }); + expect(state.visibleColumns().map((column) => column.id)).toContain('availability'); + } finally { + dispose(); + } + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts index 30ae80d19..028908905 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts @@ -47,6 +47,10 @@ interface WorkloadsControlsStateOptions { onMetricHistoryRangeChange?: (value: WorkloadTableMetricHistoryRange) => void; columnVisibilityStorageScope?: string; additionalDefaultHiddenColumnIds?: string[]; + // False when no workload in the current set has an availability check + // linked, which suppresses the Availability column instead of rendering an + // empty cell on every row. Omitted means "assume data exists". + hasAvailabilityData?: Accessor; columnLabelOverrides?: Partial>; setShowFilters: (value: boolean | ((current: boolean) => boolean)) => void; showFilters: Accessor; @@ -192,12 +196,30 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions DEFAULT_WORKLOADS_SORT_DIRECTION, ); + // The Availability cell renders nothing at all until an availability check + // is linked to that workload, so a table where no row has one shows an empty + // column under an "Avail" header and tells the reader nothing. Gate it on the + // live data rather than on a persisted preference: dropping it from the + // relevant set leaves the user's own show/hide choice untouched, and the + // column comes back on its own the first time a check is linked. + const hasAvailabilityData = createMemo(() => + options.hasAvailabilityData ? options.hasAvailabilityData() : true, + ); + const relevantColumns = createMemo(() => { const base = VIEW_MODE_COLUMNS[options.viewMode()]; - if (!base) return null; - if (effectiveGroupingMode() === 'grouped' && base.has('node')) { + const dropNode = effectiveGroupingMode() === 'grouped'; + const dropAvailability = !hasAvailabilityData(); + if (!base) { + if (!dropAvailability) return null; + const all = new Set(GUEST_COLUMNS.map((column) => column.id)); + all.delete('availability'); + return all; + } + if ((dropNode && base.has('node')) || (dropAvailability && base.has('availability'))) { const filtered = new Set(base); - filtered.delete('node'); + if (dropNode) filtered.delete('node'); + if (dropAvailability) filtered.delete('availability'); return filtered; } return base; @@ -220,11 +242,12 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions relevantColumns, {}, // One-time hides for users who already have a saved column preference for - // this scope. `backup` reads exclusively from `resource.proxmox.lastBackup`, - // so on scopes that opt into hiding it (vSphere) it renders "None" on every - // row forever. The migration only fires where the id is already in that - // scope's default-hidden set, so Proxmox keeps the column visible. - ['aiContext', 'backup'], + // this scope. `backup` reads exclusively from `resource.proxmox.lastBackup` + // and `tags` on vSphere carries only adapter provenance strings, so on the + // scopes that opt into hiding them those columns say the same thing on + // every row forever. The migration only fires where the id is already in + // that scope's default-hidden set, so Proxmox keeps both columns visible. + ['aiContext', 'backup', 'tags'], ); const visibleColumns = columnVisibility.visibleColumns; diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 01995262d..9ec3e920c 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -198,6 +198,16 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { }, ); + // Drives the Availability column's presence. Availability checks are opt-in + // per resource, so a surface where nothing is probed would otherwise carry a + // permanently empty column. Read the unfiltered guest set so narrowing the + // table with a search or status filter never makes the column vanish. + const hasAvailabilityData = createMemo(() => + allGuests().some( + (guest) => Boolean(guest.availability) || (guest.availabilityChecks?.length ?? 0) > 0, + ), + ); + const { clusterFilterConfig, clusterOptions, @@ -286,6 +296,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { onMetricHistoryRangeChange: props.onMetricHistoryRangeChange, columnVisibilityStorageScope: props.columnVisibilityStorageScope, additionalDefaultHiddenColumnIds: props.additionalDefaultHiddenColumnIds, + hasAvailabilityData, columnLabelOverrides: props.columnLabelOverrides, layoutWidth: props.layoutWidth, setShowFilters, diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 3d488d6b1..d668995c3 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -61,7 +61,18 @@ const VMWARE_WORKLOAD_COLUMN_VISIBILITY_SCOPE = 'vmware-vms'; // PowerProtect) or VMware's separately-licensed Live Recovery / SRM, // none of which surface through vCenter's inventory API. Hide the // column by default rather than render a permanently blank cell. -const VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS: readonly string[] = ['backup']; +// +// Tags is hidden for a different reason, and this one is a stopgap rather +// than the canonical fix. vCenter does have a real tag/category system, but +// the vSphere adapter never reads it: `internal/vmware/provider.go` fills +// `Resource.Tags` with fixed provenance strings instead (`vmware`, `vsphere`, +// `vm`, `source:vcenter`, `connection:`, `power:`). Five of those +// six are identical across an entire estate and the sixth restates the power +// state the status filter already owns, so the column renders the same dots on +// every row and filtering on any of them selects everything. The tags stay in +// the payload because search and facet counts consume them; only the column is +// suppressed. Drop this hide once the adapter reads vCenter's tagging API. +const VMWARE_WORKLOAD_DEFAULT_HIDDEN_COLUMN_IDS: readonly string[] = ['backup', 'tags']; const VMWARE_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ { value: 'all', label: 'All' }, { value: 'running', label: 'Powered on' },