From a15dc6b899900fca45690acde68668e04bf09a83 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 5 Dec 2025 12:12:56 +0000 Subject: [PATCH] feat: Toggleable table columns with rich tooltips for Proxmox dashboard Replace drawer-based info display with inline columns that can be toggled: - Add IP, Uptime, Node, Backup, OS, Tags columns (user-toggleable) - Add ColumnPicker dropdown to show/hide columns with localStorage persistence - Columns auto-show based on screen width using priority system - Remove GuestDrawer - all info now visible inline or via tooltips Rich hover tooltips: - Disk bar: Shows all mount points with usage %, color-coded by severity - Memory bar: Shows used/free/balloon/swap breakdown - IP column: Shows network icon + count, hover for interfaces, MACs, IPs, traffic Also: - Create useColumnVisibility hook for responsive column management - Create ColumnPicker component for column toggle UI - Update drawer layouts in Hosts/Docker tabs for consistency --- .../src/components/Dashboard/Dashboard.tsx | 131 ++-- .../components/Dashboard/DashboardFilter.tsx | 21 + .../src/components/Dashboard/GuestDrawer.tsx | 282 +++++---- .../src/components/Dashboard/GuestRow.tsx | 580 ++++++++++++------ .../components/Dashboard/StackedDiskBar.tsx | 28 +- .../components/Docker/DockerUnifiedTable.tsx | 20 +- .../src/components/Hosts/HostsOverview.tsx | 236 ++++--- .../src/components/shared/ColumnPicker.tsx | 127 ++++ .../src/hooks/useColumnVisibility.ts | 138 +++++ frontend-modern/src/utils/localStorage.ts | 5 + 10 files changed, 1023 insertions(+), 545 deletions(-) create mode 100644 frontend-modern/src/components/shared/ColumnPicker.tsx create mode 100644 frontend-modern/src/hooks/useColumnVisibility.ts diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 2df7232..0c01d30 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -1,7 +1,7 @@ import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import type { VM, Container, Node } from '@/types/api'; -import { GuestRow, GUEST_COLUMNS } from './GuestRow'; +import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -19,8 +19,10 @@ import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from import { getNodeDisplayName } from '@/utils/nodes'; import { logger } from '@/utils/logger'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; +import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { getBackupInfo } from '@/utils/format'; +import { aiChatStore } from '@/stores/aiChat'; type GuestMetadataRecord = Record; type IdleCallbackHandle = number; @@ -265,8 +267,16 @@ export function Dashboard(props: DashboardProps) { const [sortKey, setSortKey] = createSignal('vmid'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); + // Column visibility management + const columnVisibility = useColumnVisibility( + STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS, + GUEST_COLUMNS as GuestColumnDef[] + ); + const visibleColumns = columnVisibility.visibleColumns; + const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id)); + // Total columns for colspan calculations - const totalColumns = GUEST_COLUMNS.length; + const totalColumns = createMemo(() => visibleColumns().length); // Load all guest metadata on mount (single API call for all guests) onMount(async () => { @@ -457,6 +467,12 @@ export function Dashboard(props: DashboardProps) { } } else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { // If it's a printable character and user is not in an input field + // Check if AI chat is open - if so, focus that instead + if (aiChatStore.focusInput()) { + // AI chat input was focused, let the character be typed there + return; + } + // Otherwise, focus the search input // Expand filters section if collapsed if (!showFilters()) { setShowFilters(true); @@ -827,6 +843,10 @@ export function Dashboard(props: DashboardProps) { setSortKey={setSortKey} setSortDirection={setSortDirection} searchInputRef={(el) => (searchInputRef = el)} + availableColumns={columnVisibility.availableToggles()} + isColumnHidden={columnVisibility.isHiddenByUser} + onColumnToggle={columnVisibility.toggle} + onColumnReset={columnVisibility.resetToDefaults} /> {/* Loading State */} @@ -954,93 +974,25 @@ export function Dashboard(props: DashboardProps) { - {/* Name Header */} - + + {(col) => { + const isFirst = () => col.id === visibleColumns()[0]?.id; + const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined; + const isSortable = !!sortKeyForCol; + const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol; - {/* Type */} - - - {/* VMID */} - - - {/* Uptime */} - - - {/* CPU */} - - - {/* Memory */} - - - {/* Disk */} - - - {/* Disk Read */} - - - {/* Disk Write */} - - - {/* Net In */} - - - {/* Net Out */} - + return ( + + ); + }} + @@ -1059,7 +1011,7 @@ export function Dashboard(props: DashboardProps) { return ( <> - + }> {(guest) => { @@ -1080,6 +1032,7 @@ export function Dashboard(props: DashboardProps) { parentNodeOnline={parentNodeOnline} onCustomUrlUpdate={handleCustomUrlUpdate} isGroupedView={groupingMode() === 'grouped'} + visibleColumnIds={visibleColumnIds()} /> ); diff --git a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx index d110057..6b22892 100644 --- a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx +++ b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx @@ -2,6 +2,8 @@ import { Component, Show, For, createSignal, onMount, createEffect, onCleanup } import { Card } from '@/components/shared/Card'; import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover'; import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle'; +import { ColumnPicker } from '@/components/shared/ColumnPicker'; +import type { ColumnDef } from '@/hooks/useColumnVisibility'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { createSearchHistoryManager } from '@/utils/searchHistory'; @@ -20,6 +22,11 @@ interface DashboardFilterProps { setSortKey: (value: string) => void; setSortDirection: (value: string) => void; searchInputRef?: (el: HTMLInputElement) => void; + // Column visibility + availableColumns?: ColumnDef[]; + isColumnHidden?: (id: string) => boolean; + onColumnToggle?: (id: string) => void; + onColumnReset?: () => void; } export const DashboardFilter: Component = (props) => { @@ -423,6 +430,20 @@ export const DashboardFilter: Component = (props) => { + {/* Column Picker */} + + + + + + + + {/* Reset Button */} - {/* Name */} + {/* Name - always visible */} + + + {/* VMID */} - - - {/* Uptime */} - + + + {/* CPU */} - + + {/* Memory */} - + + + {/* Disk */} + + + + - {/* Disk */} - + + + {/* Uptime */} + + + + + {/* Node - NEW */} + + + + + {/* Backup Status - NEW */} + + + + + {/* OS - NEW */} + + + + + {/* Tags */} + + + + {/* Disk Read */} - + + + {/* Disk Write */} - + + + {/* Net In */} - + + + {/* Net Out */} - - - - - - - - - + + ); } diff --git a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx index 98aa642..cd98d67 100644 --- a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx @@ -122,9 +122,14 @@ export function StackedDiskBar(props: StackedDiskBarProps) { }); }); + // Check if we have any disk details to show + const hasDisks = createMemo(() => { + return props.disks && props.disks.length > 0; + }); + // Generate tooltip content const tooltipContent = createMemo(() => { - if (hasMultipleDisks() && props.disks) { + if (hasDisks() && props.disks) { return props.disks.map((disk, idx) => { const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0; const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`; @@ -139,6 +144,17 @@ export function StackedDiskBar(props: StackedDiskBarProps) { }; }); } + // Fallback for aggregate disk + if (props.aggregateDisk && props.aggregateDisk.total > 0) { + const percent = (props.aggregateDisk.used / props.aggregateDisk.total) * 100; + return [{ + label: 'Total', + used: formatBytes(props.aggregateDisk.used, 0), + total: formatBytes(props.aggregateDisk.total, 0), + percent: formatPercent(percent), + color: getUsageColor(percent), + }]; + } return []; }); @@ -162,7 +178,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) { }); const handleMouseEnter = (e: MouseEvent) => { - if (hasMultipleDisks()) { + if (tooltipContent().length > 0) { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); setShowTooltip(true); @@ -227,8 +243,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) { - {/* Tooltip for multi-disk breakdown */} - + {/* Tooltip for disk breakdown */} + 0}>
- Disk Breakdown + {hasMultipleDisks() ? 'Disk Breakdown' : 'Disk Usage'}
{(item, idx) => (
0 }}> {item.label} diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 874dcd0..86f5540 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1379,8 +1379,8 @@ const DockerContainerRow: Component<{
handleSort('name')} - > - Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('type')} - > - Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('vmid')} - > - VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('uptime')} - > - Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('cpu')} - > - CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('memory')} - > - Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('disk')} - > - Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('diskRead')} - > - Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('diskWrite')} - > - Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('networkIn')} - > - Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('networkOut')} - > - Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')} - isSortable && handleSort(sortKeyForCol!)} + > + {col.label} {isSorted() && (sortDirection() === 'asc' ? '▲' : '▼')} +
@@ -544,7 +630,10 @@ export function GuestRow(props: GuestRowProps) { - + {/* Show backup indicator in name cell only if backup column is hidden */} + + +
} > @@ -599,7 +688,8 @@ export function GuestRow(props: GuestRowProps) {
- + {/* Show tags inline only if tags column is hidden */} +
-
- - {isVM(props.guest) ? 'VM' : 'LXC'} - -
-
+
+ + {isVM(props.guest) ? 'VM' : 'LXC'} + +
+
-
- {props.guest.vmid} -
-
-
- - - - {formatUptime(props.guest.uptime, true)} - - - -
-
+
+ {props.guest.vmid} +
+
- -
+ +
+ +
+ +
+
+ - - -
-
- -
- +
+
+ +
+ +
+
+ +
+
+ + + - + + + } + > + +
+ {formatPercent(diskPercent())} +
+
+
+
- - -
- - - - + {/* IP Address with Network Tooltip */} + + +
+ 0 || hasNetworkInterfaces()} fallback={-}> + + +
+
+
+ + + + {formatUptime(props.guest.uptime, true)} + + + +
+
+
+ + {props.guest.node} + +
+
+
+ + {(() => { + const info = getBackupInfo(props.guest.lastBackup); + const config = BACKUP_STATUS_CONFIG[info.status]; + return ( + + {info.status === 'fresh' && 'OK'} + {info.status === 'stale' && info.ageFormatted} + {info.status === 'critical' && info.ageFormatted} + {info.status === 'never' && 'Never'} + + ); + })()} + + + - + +
+
+
+ -}> + + {osName() || osVersion()} -
- } - > - -
- {formatPercent(diskPercent())} -
-
-
- +
+
+
event.stopPropagation()}> +
- -
-
- -}> - {formatSpeed(diskRead())} - -
-
+
+ -}> + {formatSpeed(diskRead())} + +
+
-
- -}> - {formatSpeed(diskWrite())} - -
-
+
+ -}> + {formatSpeed(diskWrite())} + +
+
-
- -}> - {formatSpeed(networkIn())} - -
-
+
+ -}> + {formatSpeed(networkIn())} + +
+
-
- -}> - {formatSpeed(networkOut())} - -
-
- setCurrentlyExpandedGuestId(null)} - /> + + +
+ -}> + {formatSpeed(networkOut())} + +
-
-
+
+
Summary
@@ -1509,7 +1509,7 @@ const DockerContainerRow: Component<{
0}> -
+
Ports
@@ -1529,7 +1529,7 @@ const DockerContainerRow: Component<{ 0}> -
+
Networks
@@ -1556,7 +1556,7 @@ const DockerContainerRow: Component<{ -
+
Podman Metadata
@@ -1590,7 +1590,7 @@ const DockerContainerRow: Component<{ -
+
Block I/O
@@ -1626,7 +1626,7 @@ const DockerContainerRow: Component<{ -
+
Mounts
@@ -1691,7 +1691,7 @@ const DockerContainerRow: Component<{ 0}> -
+
Labels
@@ -2155,8 +2155,8 @@ const DockerServiceRow: Component<{
-
-
+
+
Tasks diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 2e8e3ce..94912bb 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -608,64 +608,56 @@ export const HostsOverview: Component = (props) => {
-
+
{/* System Info */} -
-
System
-
+
+
System
+
-
- CPUs - {host.cpuCount} +
+ CPUs + {host.cpuCount}
0}> -
- Load Avg - {host.loadAverage!.map(l => l.toFixed(2)).join(', ')} +
+ Load Avg + {host.loadAverage!.map(l => l.toFixed(2)).join(', ')}
-
- Arch - {host.architecture} +
+ Arch + {host.architecture}
-
- Kernel - {host.kernelVersion} +
+ Kernel + {host.kernelVersion}
-
- Agent - {host.agentVersion} +
+ Agent + {host.agentVersion}
- {/* Network Interfaces */} - 0}> -
-
Network
-
- - {(iface) => ( -
-
{iface.name}
- 0}> -
- - {(addr) => ( - - {addr} - - )} - -
-
+ {/* Temperature Sensors */} + 0}> +
+
Temperatures
+
+ + {([name, temp]) => ( +
+ {name} + 80 ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-200'}`}> + {temp.toFixed(1)}°C +
)}
@@ -673,19 +665,64 @@ export const HostsOverview: Component = (props) => {
+ {/* RAID Arrays */} + + {(array) => { + const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0; + const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0; + const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean'); + + const stateColor = () => { + if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold'; + if (isRebuilding()) return 'text-amber-600 dark:text-amber-400'; + if (isHealthy()) return 'text-green-600 dark:text-green-400'; + return 'text-gray-700 dark:text-gray-200'; + }; + + return ( +
+
+ RAID {array.level.replace('raid', '')} - {array.device} +
+
+
+ State + {array.state} +
+
+ Devices + + {array.activeDevices}/{array.totalDevices} + {array.failedDevices > 0 && ({array.failedDevices} failed)} + +
+ 0}> +
+ Rebuild + + {array.rebuildPercent.toFixed(1)}% + +
+
+
+
+ ); + }} +
+ {/* Disk Info */} 0}> -
-
Disks
-
+
+
Disks
+
{(disk) => { const diskPercent = () => disk.usage ?? 0; return ( -
+
{disk.mountpoint || disk.device} - + {formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
@@ -709,14 +746,14 @@ export const HostsOverview: Component = (props) => { {/* Disk I/O */} 0}> -
-
Disk I/O
-
- +
+
Disk I/O
+
+ {(io) => ( -
-
{io.device}
-
+
+
{io.device}
+
Read: {formatBytes(io.readBytes ?? 0, 1)} @@ -725,14 +762,6 @@ export const HostsOverview: Component = (props) => { Write: {formatBytes(io.writeBytes ?? 0, 1)}
-
- Read Ops: - {formatNumber(io.readOps ?? 0)} -
-
- Write Ops: - {formatNumber(io.writeOps ?? 0)} -
)} @@ -741,76 +770,31 @@ export const HostsOverview: Component = (props) => {
- {/* Temperature Sensors */} - 0}> -
-
Temperatures
-
- - {([name, temp]) => ( -
- {name} - 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}> - {temp.toFixed(1)}°C - -
- )} -
-
-
-
- - {/* RAID Arrays */} - 0}> - - {(array) => { - const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0; - const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0; - const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean'); - - const stateColor = () => { - if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold'; - if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold'; - if (isHealthy()) return 'text-green-600 dark:text-green-400'; - return 'text-gray-600 dark:text-gray-300'; - }; - - return ( -
-
- RAID {array.level.replace('raid', '')} - {array.device} -
-
-
- State - {array.state} -
-
- Devices - - {array.activeDevices}/{array.totalDevices} - {array.failedDevices > 0 && ({array.failedDevices} failed)} - -
- 0}> -
- Rebuild - - {array.rebuildPercent.toFixed(1)}% - + {/* Network Interfaces */} + 0}> +
+
Network
+
+ + {(iface) => ( +
+
{iface.name}
+ 0}> +
+ + {(addr) => ( + + {addr} + + )} +
- -
- Speed - {array.rebuildSpeed} -
-
-
- ); - }} - + )} + +
+
diff --git a/frontend-modern/src/components/shared/ColumnPicker.tsx b/frontend-modern/src/components/shared/ColumnPicker.tsx new file mode 100644 index 0000000..d372b61 --- /dev/null +++ b/frontend-modern/src/components/shared/ColumnPicker.tsx @@ -0,0 +1,127 @@ +import { Component, Show, For, createSignal, onCleanup, createEffect } from 'solid-js'; +import type { ColumnDef } from '@/hooks/useColumnVisibility'; + +interface ColumnPickerProps { + /** Columns that can be toggled */ + columns: ColumnDef[]; + /** Check if a column is currently hidden */ + isHidden: (id: string) => boolean; + /** Toggle a column's visibility */ + onToggle: (id: string) => void; + /** Reset all columns to visible */ + onReset?: () => void; +} + +export const ColumnPicker: Component = (props) => { + const [isOpen, setIsOpen] = createSignal(false); + let containerRef: HTMLDivElement | undefined; + + // Close on click outside + const handleClickOutside = (e: MouseEvent) => { + if (containerRef && !containerRef.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + createEffect(() => { + if (isOpen()) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + }); + + onCleanup(() => { + document.removeEventListener('mousedown', handleClickOutside); + }); + + // Count how many are hidden + const hiddenCount = () => props.columns.filter(c => props.isHidden(c.id)).length; + + return ( +
+ + + +
+
+
+ Show Columns + 0}> + + +
+
+ +
+ + {(col) => { + const isChecked = () => !props.isHidden(col.id); + return ( + + ); + }} + +
+ + +
+ No columns available to toggle at this screen size +
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/hooks/useColumnVisibility.ts b/frontend-modern/src/hooks/useColumnVisibility.ts new file mode 100644 index 0000000..192ff5c --- /dev/null +++ b/frontend-modern/src/hooks/useColumnVisibility.ts @@ -0,0 +1,138 @@ +import { createMemo, Accessor } from 'solid-js'; +import { usePersistentSignal } from './usePersistentSignal'; +import { useBreakpoint, type ColumnPriority, PRIORITY_BREAKPOINTS, type Breakpoint } from './useBreakpoint'; + +export interface ColumnDef { + id: string; + label: string; + priority: ColumnPriority; + toggleable?: boolean; + minWidth?: string; + maxWidth?: string; + flex?: number; + sortKey?: string; +} + +const BREAKPOINT_ORDER: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; + +function breakpointIndex(bp: Breakpoint): number { + return BREAKPOINT_ORDER.indexOf(bp); +} + +/** + * Hook for managing column visibility with persistence and responsive behavior. + * + * Columns are shown if: + * 1. The current breakpoint supports their priority level, AND + * 2. The user hasn't explicitly hidden them (for toggleable columns) + * + * @param storageKey - localStorage key for persisting user preferences + * @param columns - Array of column definitions + */ +export function useColumnVisibility( + storageKey: string, + columns: ColumnDef[] +) { + const { breakpoint } = useBreakpoint(); + + // Get list of toggleable column IDs + const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id); + + // Persist hidden columns to localStorage + const [hiddenColumns, setHiddenColumns] = usePersistentSignal( + storageKey, + [], + { + serialize: (arr) => JSON.stringify(arr), + deserialize: (str) => { + try { + const parsed = JSON.parse(str); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, + } + ); + + // Check if a column is hidden by user preference + const isHiddenByUser = (id: string): boolean => { + return hiddenColumns().includes(id); + }; + + // Check if breakpoint supports showing this column + const hasSpaceForColumn = (col: ColumnDef): boolean => { + const minBreakpoint = PRIORITY_BREAKPOINTS[col.priority]; + return breakpointIndex(breakpoint()) >= breakpointIndex(minBreakpoint); + }; + + // Toggle a column's visibility + const toggle = (id: string) => { + const current = hiddenColumns(); + if (current.includes(id)) { + setHiddenColumns(current.filter(c => c !== id)); + } else { + setHiddenColumns([...current, id]); + } + }; + + // Show a column (remove from hidden) + const show = (id: string) => { + setHiddenColumns(hiddenColumns().filter(c => c !== id)); + }; + + // Hide a column (add to hidden) + const hide = (id: string) => { + if (!hiddenColumns().includes(id)) { + setHiddenColumns([...hiddenColumns(), id]); + } + }; + + // Reset to defaults (show all) + const resetToDefaults = () => { + setHiddenColumns([]); + }; + + // Compute visible columns based on breakpoint and user preferences + const visibleColumns: Accessor = createMemo(() => { + return columns.filter(col => { + // Always show essential columns regardless of breakpoint + if (col.priority === 'essential') return true; + + // Check if screen has space for this priority level + if (!hasSpaceForColumn(col)) return false; + + // If toggleable, check user preference + if (col.toggleable && isHiddenByUser(col.id)) return false; + + return true; + }); + }); + + // Get columns that could be toggled at the current breakpoint + // (i.e., screen is wide enough to show them) + const availableToggles: Accessor = createMemo(() => { + return columns.filter(col => { + if (!col.toggleable) return false; + return hasSpaceForColumn(col); + }); + }); + + // Check if a specific column is currently visible + const isColumnVisible = (id: string): boolean => { + return visibleColumns().some(col => col.id === id); + }; + + return { + visibleColumns, + availableToggles, + toggleableIds, + hiddenColumns, + isColumnVisible, + isHiddenByUser, + toggle, + show, + hide, + resetToDefaults, + }; +} diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 1e0af9b..e1279ef 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -108,4 +108,9 @@ export const STORAGE_KEYS = { // Metrics display METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines' + + // Column visibility + DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns', + HOSTS_HIDDEN_COLUMNS: 'hostsHiddenColumns', + DOCKER_HIDDEN_COLUMNS: 'dockerHiddenColumns', } as const;