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' },