From f32d22dc41e9cfeb5cdea8546b6e40d1423b340a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 29 Nov 2025 21:20:02 +0000 Subject: [PATCH] Convert data tables from CSS Grid to HTML tables - Replace CSS Grid with native elements across all data tables - HTML tables naturally align columns based on widest content in each column - Add min-width on CPU/Memory/Disk columns for proper progress bar display - Remove responsive column header abbreviations (show full labels always) - Remove icon components from NodeSummaryTable headers - Simplify TemperatureGauge to text-only display (no progress bar) Tables converted: Dashboard guest table, NodeSummaryTable, HostsOverview, DockerUnifiedTable, DockerHostSummaryTable --- .../src/components/Dashboard/Dashboard.tsx | 301 +++--- .../src/components/Dashboard/GuestRow.tsx | 506 +++++----- .../Docker/DockerHostSummaryTable.tsx | 46 +- .../components/Docker/DockerUnifiedTable.tsx | 316 ++++--- .../src/components/Hosts/HostsOverview.tsx | 865 +++++++++--------- .../components/shared/NodeSummaryTable.tsx | 838 +++++++---------- .../components/shared/TemperatureGauge.tsx | 54 +- .../shared/responsive/useGridTemplate.ts | 19 +- 8 files changed, 1390 insertions(+), 1555 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index b7f1fee..b6a9e92 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -2,7 +2,6 @@ import { createSignal, createMemo, createEffect, For, Show, onMount } from 'soli import { useNavigate } from '@solidjs/router'; import type { VM, Container, Node } from '@/types/api'; import { GuestRow, GUEST_COLUMNS } from './GuestRow'; -import { useGridTemplate } from '@/components/shared/responsive'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -259,9 +258,8 @@ export function Dashboard(props: DashboardProps) { const [sortKey, setSortKey] = createSignal('vmid'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); - // Use the same grid template as GuestRow for header alignment - const { gridTemplate: headerGridTemplate } = useGridTemplate({ columns: GUEST_COLUMNS }); - + // Total columns for colspan calculations + const totalColumns = GUEST_COLUMNS.length; // Load all guest metadata on mount (single API call for all guests) onMount(async () => { @@ -929,165 +927,148 @@ export function Dashboard(props: DashboardProps) { {/* Table View */} 0}> - +
- {/* Desktop Header */} -
- {/* Name Header */} -
handleSort('name')} - > - Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} -
+
+ + + {/* Name Header */} + - {/* Type */} -
handleSort('type')} - title="Type" - > - - T - {sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* VMID */} -
handleSort('vmid')} - title="VMID" - > - - ID - {sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* Uptime */} -
handleSort('uptime')} - title="Uptime" - > - - Up - {sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* CPU */} -
handleSort('cpu')} - > - CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} -
- {/* Memory */} -
handleSort('memory')} - title="Memory" - > - - Mem - {sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* Disk */} -
handleSort('disk')} - > - Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} -
- {/* Disk Read */} -
handleSort('diskRead')} - title="Disk Read" - > - - D Rd - {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* Disk Write */} -
handleSort('diskWrite')} - title="Disk Write" - > - - D Wr - {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* Net In */} -
handleSort('networkIn')} - title="Net In" - > - - N In - {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- {/* Net Out */} -
handleSort('networkOut')} - title="Net Out" - > - - N Out - {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} -
- + {/* Type */} + - {/* Guest List */} -
- { - const nodeA = nodeByInstance()[instanceIdA]; - const nodeB = nodeByInstance()[instanceIdB]; - const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; - const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB; - return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB); - })} - fallback={<>} - > - {([instanceId, guests]) => { - const node = nodeByInstance()[instanceId]; - return ( - <> - - - - }> - {(guest) => { - const guestId = guest.id || `${guest.instance}-${guest.vmid}`; - const metadata = - guestMetadata()[guestId] || - guestMetadata()[`${guest.node}-${guest.vmid}`]; - const parentNode = node ?? resolveParentNode(guest); - const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; - return ( - - - - ); - }} - - - ); - }} - -
+ {/* VMID */} + + + {/* Uptime */} + + + {/* CPU */} + + + {/* Memory */} + + + {/* Disk */} + + + {/* Disk Read */} + + + {/* Disk Write */} + + + {/* Net In */} + + + {/* Net Out */} + + + + + { + const nodeA = nodeByInstance()[instanceIdA]; + const nodeB = nodeByInstance()[instanceIdB]; + const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; + const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB; + return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB); + })} + fallback={<>} + > + {([instanceId, guests]) => { + const node = nodeByInstance()[instanceId]; + return ( + <> + + + + }> + {(guest) => { + const guestId = guest.id || `${guest.instance}-${guest.vmid}`; + const metadata = + guestMetadata()[guestId] || + guestMetadata()[`${guest.node}-${guest.vmid}`]; + const parentNode = node ?? resolveParentNode(guest); + const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; + return ( + + + + ); + }} + + + ); + }} + + +
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' ? '▲' : '▼')} +
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index eb136b7..208b07e 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,5 +1,5 @@ import { GuestDrawer } from './GuestDrawer'; -import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; +import { createMemo, createSignal, createEffect, Show } from 'solid-js'; import type { VM, Container } from '@/types/api'; import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format'; import { TagBadges } from './TagBadges'; @@ -13,7 +13,8 @@ import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; import { buildMetricKey } from '@/utils/metricsKeys'; import { type ColumnPriority } from '@/hooks/useBreakpoint'; -import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive'; +import { ResponsiveMetricCell } from '@/components/shared/responsive'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; type Guest = VM | Container; @@ -113,19 +114,19 @@ interface ColumnDef { } export const GUEST_COLUMNS: ColumnDef[] = [ - { id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', maxWidth: '300px' }, - { id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' }, - { id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' }, - { id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' }, - // Metric columns - fixed width to match progress bar max-width (140px + padding) - { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, - { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, - { id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, - // I/O columns - fixed width - { id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, - { id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, - { id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' }, - { id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '56px', maxWidth: '70px' }, + { id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + // Metric columns - fixed min/max width to match progress bar + { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' }, + { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' }, + { id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' }, + // I/O columns - auto sizing + { id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, + { id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' }, ]; interface GuestRowProps { @@ -156,8 +157,8 @@ export function GuestRow(props: GuestRowProps) { const guestId = createMemo(() => buildGuestId(props.guest)); const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId()); - // Use the responsive grid template hook for dynamic column visibility - const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS }); + // Use breakpoint hook directly for responsive behavior + const { isMobile } = useBreakpoint(); // Create namespaced metrics key const metricsKey = createMemo(() => { @@ -485,142 +486,146 @@ export function GuestRow(props: GuestRowProps) { }; }); - // Render cell content based on column type - const renderCell = (column: ColumnDef) => { - switch (column.id) { - case 'name': - return ( -
-
-
- - - - - + } + > +
+ { + editingValues.set(guestId(), e.currentTarget.value); + setEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://192.168.1.100:8006" + class="min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" /> + +
- - - - Lock: {lockLabel()} - -
-
- ); - case 'type': - return ( -
+ + + + + + + Lock: {lockLabel()} + + +
+ + + {/* Type */} + +
- ); + - case 'vmid': - return ( -
+ {/* VMID */} + +
{props.guest.vmid}
- ); + - case 'uptime': - return ( -
-
+ {/* Uptime */} + +
+ {formatUptime(props.guest.uptime, true)} -
+
- ); + - case 'cpu': - return ( -
+ {/* CPU */} + + +
+ +
+
+ - ); + - case 'memory': - return ( -
-
- -
- -
-
- -
-
- ); - - case 'disk': - return ( -
- - - - - } - > - {/* Mobile: simple percentage text */} - -
- {formatPercent(diskPercent())} -
-
- {/* Desktop: stacked disk bar for multiple disks */} -
- +
+ +
+
+
- ); + - case 'diskRead': - return ( -
+ {/* Disk */} + + + + - + +
+ } + > + +
+ {formatPercent(diskPercent())} +
+
+
+ +
+ + + + {/* Disk Read */} + +
-}> {formatSpeed(diskRead())}
- ); + - case 'diskWrite': - return ( -
+ {/* Disk Write */} + +
-}> {formatSpeed(diskWrite())}
- ); + - case 'netIn': - return ( -
+ {/* Net In */} + +
-}> {formatSpeed(networkIn())}
- ); + - case 'netOut': - return ( -
+ {/* Net Out */} + +
-}> {formatSpeed(networkOut())}
- ); - - default: - return null; - } - }; - - return ( - <> -
- - {(column) => renderCell(column)} - -
+ + -
-
+ + setCurrentlyExpandedGuestId(null)} /> -
-
+ +
); diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index 5fb54dc..f032def 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -136,12 +136,12 @@ export const DockerHostSummaryTable: Component = (p return ( - - + +
- + - - - - - - - - + ); }; const DockerContainerRow: Component<{ row: Extract; - visibleColumns: Accessor; - gridTemplate: Accessor; isMobile: Accessor; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; @@ -1172,14 +1172,14 @@ const DockerContainerRow: Component<{ ); case 'image': return ( -
- {container.image || '—'} +
+ {container.image || '—'}
); case 'status': return ( -
- {statusLabel()} +
+ {statusLabel()}
); case 'cpu': @@ -1270,24 +1270,32 @@ const DockerContainerRow: Component<{ return ( <> -
- - {(column) => renderCell(column)} + + {(column) => ( +
+ )} - + -
-
-
-
- Summary -
+
+ + ); @@ -1624,8 +1634,6 @@ const DockerContainerRow: Component<{ const DockerServiceRow: Component<{ row: Extract; - visibleColumns: Accessor; - gridTemplate: Accessor; isMobile: Accessor; customUrl?: string; onCustomUrlUpdate?: (resourceId: string, url: string) => void; @@ -1923,8 +1931,8 @@ const DockerServiceRow: Component<{ ); case 'image': return ( -
- {service.image || '—'} +
+ {service.image || '—'}
); case 'status': @@ -1967,29 +1975,37 @@ const DockerServiceRow: Component<{ return ( <> -
- - {(column) => renderCell(column)} + + {(column) => ( +
+ )} - + -
-
-
-
- Tasks - - {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'} - -
-
-
handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} tabIndex={0} @@ -150,47 +150,47 @@ export const DockerHostSummaryTable: Component = (p > Host {renderSortIndicator('name')} + Status handleSort('cpu')} > CPU {renderSortIndicator('cpu')} handleSort('memory')} > Memory {renderSortIndicator('memory')} handleSort('disk')} > Disk {renderSortIndicator('disk')} handleSort('running')} > Containers {renderSortIndicator('running')} -
+
+
{renderDockerStatusBadge(summary.host.status)}
+ -
+
@@ -298,7 +298,7 @@ export const DockerHostSummaryTable: Component = (p />
+ = (p showMobile={isMobile()} /> + = (p showMobile={isMobile()} /> -
+
+
0} fallback={} @@ -333,14 +333,14 @@ export const DockerHostSummaryTable: Component = (p
{uptimeLabel}
= (p
; - visibleColumns: Accessor; + columnCount: number; }> = (props) => { const displayName = getHostDisplayName(props.host); const hostStatus = () => getDockerHostStatusIndicator(props.host); const isOnline = () => hostStatus().variant === 'success'; return ( -
-
- +
+
- {displayName} - - - ({props.host.hostname}) - - -
- + > + + {displayName} + + + ({props.host.hostname}) + + + +
+ {renderCell(column)} +
+
+
+
+
+ Summary +
Runtime @@ -1614,9 +1622,11 @@ const DockerContainerRow: Component<{ })}
- -
-
+ +
+ +
+ {renderCell(column)} +
+ +
+
+
+
+
+ Tasks + + {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'} + +
+
+ @@ -2095,11 +2111,13 @@ const DockerServiceRow: Component<{ }} -
Task
+
+
+
-
-
+ + ); @@ -2114,8 +2132,8 @@ const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => { }; const DockerUnifiedTable: Component = (props) => { - // Use the responsive grid template hook for dynamic column visibility - const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS }); + // Use the breakpoint hook for responsive behavior + const { isMobile } = useBreakpoint(); // Caches for stable object references to prevent re-animations const rowCache = new Map(); @@ -2442,8 +2460,6 @@ const DockerUnifiedTable: Component = (props) => { return row.kind === 'container' ? ( = (props) => { ) : ( = (props) => { >
- {/* Header Row */} -
- - {(column) => { - const col = column as DockerColumnDef; - const colSortKey = col.sortKey as SortKey | undefined; - const isResource = col.id === 'resource'; - return ( -
colSortKey && handleSort(colSortKey)} - onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} - tabIndex={0} - role="button" - aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'} - > - -
- {col.label} - {colSortKey && renderSortIndicator(colSortKey)} - - Grouped by host + + + + + {(column) => { + const col = column as DockerColumnDef; + const colSortKey = col.sortKey as SortKey | undefined; + const isResource = col.id === 'resource'; + return ( + + ); + }} - } - > - - {(group) => ( - <> - - {(row) => renderRow(row, true)} - - )} - - - + + + + + {(row) => renderRow(row, false)} + + } + > + + {(group) => ( + <> + + {(row) => renderRow(row, true)} + + )} + + + +
colSortKey && handleSort(colSortKey)} + onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} + tabIndex={0} + role="button" + aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} + aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'} + > + +
+ {col.label} + {colSortKey && renderSortIndicator(colSortKey)} + + Grouped by host + + + + +
- - + +
+ {col.label} + {colSortKey && renderSortIndicator(colSortKey)} +
- -
- -
- - {col.shortLabel || col.label} - {colSortKey && renderSortIndicator(colSortKey)} -
-
- - ); - }} - - - - {/* Rows */} -
- - {(row) => renderRow(row, false)} +
diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 2ce438a..fdfda33 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -11,27 +11,16 @@ import { HostsFilter } from './HostsFilter'; import { useWebSocket } from '@/App'; import { StatusDot } from '@/components/shared/StatusDot'; import { getHostStatusIndicator } from '@/utils/status'; -import { MetricText, useGridTemplate } from '@/components/shared/responsive'; +import { MetricText } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; -import type { ColumnConfig } from '@/types/responsive'; -import { STANDARD_COLUMNS } from '@/types/responsive'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; // Global drawer state to persist across re-renders const drawerState = new Map(); -type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime'; - -const HOST_COLUMNS: ColumnConfig[] = [ - { ...STANDARD_COLUMNS.name, label: 'Host', minWidth: '200px', sortKey: 'name' }, - { id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' }, - { ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' }, - { ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' }, - { id: 'temperature', label: 'Temp', priority: 'secondary', minWidth: '80px', maxWidth: '100px' }, - { id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' }, - { ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' }, -]; +type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; interface HostsOverviewProps { hosts: Host[]; @@ -45,18 +34,21 @@ export const HostsOverview: Component = (props) => { const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); const [sortKey, setSortKey] = createSignal('name'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); + const { isMobile } = useBreakpoint(); - const handleSort = (key: string) => { + const handleSort = (key: SortKey) => { if (sortKey() === key) { setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); } else { - setSortKey(key as SortKey); + setSortKey(key); setSortDirection('asc'); } }; - // Use responsive grid template - const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS }); + const renderSortIndicator = (key: SortKey) => { + if (sortKey() !== key) return null; + return sortDirection() === 'asc' ? '▲' : '▼'; + }; // Keyboard listener to auto-focus search let searchInputRef: HTMLInputElement | undefined; @@ -116,6 +108,12 @@ export const HostsOverview: Component = (props) => { case 'memory': comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0); break; + case 'disk': { + const aDisk = a.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; + const bDisk = b.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0; + comparison = aDisk - bDisk; + break; + } case 'uptime': comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0); break; @@ -154,177 +152,46 @@ export const HostsOverview: Component = (props) => { return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); }); - const renderCell = (column: ColumnConfig, host: Host) => { - const cpuPercent = () => host.cpuUsage ?? 0; - const memPercent = () => host.memory?.usage ?? 0; + const getTemperatureValue = (host: Host) => { + if (!host.sensors?.temperatureCelsius) return null; + const temps = host.sensors.temperatureCelsius; - const hostStatus = createMemo(() => getHostStatusIndicator(host)); + // Try to find a "package" or "composite" temperature first + const packageKey = Object.keys(temps).find(k => + k.toLowerCase().includes('package') || + k.toLowerCase().includes('composite') || + k.toLowerCase().includes('tctl') + ); - switch (column.id) { - case 'name': - return ( -
-
- -
-

- {host.displayName || host.hostname || host.id} -

- -

- {host.hostname} -

-
- -

- Updated {formatRelativeTime(host.lastSeen!)} -

-
-
-
-
- ); - case 'platform': - return ( -
-
-

{host.platform || '—'}

- -

- {host.osName} {host.osVersion} -

-
-
-
- ); - case 'cpu': - return ( -
- -
- -
-
- -
- ); - case 'memory': - return ( -
- -
- -
-
- -
- ); - case 'temperature': - const tempValue = (() => { - if (!host.sensors?.temperatureCelsius) return null; - const temps = host.sensors.temperatureCelsius; + if (packageKey) return temps[packageKey]; - // Try to find a "package" or "composite" temperature first - const packageKey = Object.keys(temps).find(k => - k.toLowerCase().includes('package') || - k.toLowerCase().includes('composite') || - k.toLowerCase().includes('tctl') - ); - - if (packageKey) return temps[packageKey]; - - // Fallback: average of all core temps - const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core')); - if (coreKeys.length > 0) { - const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0); - return sum / coreKeys.length; - } - - // Fallback: just take the first available value if any - const values = Object.values(temps); - if (values.length > 0) return values[0]; - - return null; - })(); - - return ( -
- —}> - - -
- ); - case 'disk': - const diskStats = (() => { - if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 }; - const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0); - const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0); - return { - percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0, - used: totalUsed, - total: totalSize - }; - })(); - const diskPercent = diskStats.percent; - - - return ( -
- -
- -
-
- -
- ); - case 'uptime': - return ( -
- —} - > - - {formatUptime(host.uptimeSeconds!)} - - -
- ); - default: - return null; + // Fallback: average of all core temps + const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core')); + if (coreKeys.length > 0) { + const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0); + return sum / coreKeys.length; } + + // Fallback: just take the first available value if any + const values = Object.values(temps); + if (values.length > 0) return values[0]; + + return null; }; + const getDiskStats = (host: Host) => { + if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 }; + const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0); + const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0); + return { + percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0, + used: totalUsed, + total: totalSize + }; + }; + + const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; + return (
@@ -397,277 +264,403 @@ export const HostsOverview: Component = (props) => { >
- {/* Header */} -
- - {(column) => ( -
column.sortable && column.sortKey && handleSort(column.sortKey)} + + + + + + + + + + + + + + + {(host) => { + // Drawer state + const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); - {/* Rows */} -
- - {(host) => { - // Drawer state - const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); + // Check if we have additional info to show in drawer + const hasDrawerContent = createMemo(() => { + return ( + (host.disks && host.disks.length > 0) || + (host.networkInterfaces && host.networkInterfaces.length > 0) || + (host.raid && host.raid.length > 0) || + host.loadAverage || + host.cpuCount || + host.kernelVersion || + host.architecture || + host.agentVersion || + (host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0) + ); + }); + + const toggleDrawer = (event: MouseEvent) => { + if (!hasDrawerContent()) return; + const target = event.target as HTMLElement; + if (target.closest('a, button, [data-prevent-toggle]')) { + return; + } + setDrawerOpen((prev) => !prev); + }; + + // Sync drawer state + createEffect(on(() => host.id, (id) => { + const stored = drawerState.get(id); + if (stored !== undefined) { + setDrawerOpen(stored); + } else { + setDrawerOpen(false); + } + })); + + createEffect(() => { + drawerState.set(host.id, drawerOpen()); + }); + + const rowClass = () => { + const base = 'transition-all duration-200'; + const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; + const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; + const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; + return `${base} ${hover} ${clickable} ${expanded}`; + }; + + const hostStatus = createMemo(() => getHostStatusIndicator(host)); + const cpuPercent = host.cpuUsage ?? 0; + const memPercent = host.memory?.usage ?? 0; + const tempValue = getTemperatureValue(host); + const diskStats = getDiskStats(host); - // Check if we have additional info to show in drawer - const hasDrawerContent = createMemo(() => { return ( - (host.disks && host.disks.length > 0) || - (host.networkInterfaces && host.networkInterfaces.length > 0) || - (host.raid && host.raid.length > 0) || - host.loadAverage || - host.cpuCount || - host.kernelVersion || - host.architecture || - host.agentVersion || - (host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0) - ); - }); - - const toggleDrawer = (event: MouseEvent) => { - if (!hasDrawerContent()) return; - const target = event.target as HTMLElement; - if (target.closest('a, button, [data-prevent-toggle]')) { - return; - } - setDrawerOpen((prev) => !prev); - }; - - // Sync drawer state - createEffect(on(() => host.id, (id) => { - const stored = drawerState.get(id); - if (stored !== undefined) { - setDrawerOpen(stored); - } else { - setDrawerOpen(false); - } - })); - - createEffect(() => { - drawerState.set(host.id, drawerOpen()); - }); - - const rowClass = () => { - const base = 'grid items-center transition-all duration-200'; - const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; - const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; - const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; - return `${base} ${hover} ${clickable} ${expanded}`; - }; - - return ( - <> -
- - {(column) => renderCell(column, host)} - -
- - {/* Drawer - Additional Info */} - -
-
- {/* System Info */} -
-
System
-
- -
- CPUs - {host.cpuCount} -
+ <> +
+ {/* Host Name */} + - {/* Network Interfaces */} - 0}> -
-
Network
-
- - {(iface) => ( -
-
{iface.name}
- 0}> -
- - {(addr) => ( - - {addr} - - )} - -
-
-
- )} -
-
+ {/* Platform */} +
+ + {/* CPU */} + - {/* Disk Info */} - 0}> -
-
Disks
-
- - {(disk) => { - const diskPercent = () => disk.usage ?? 0; - return ( -
-
- {disk.mountpoint || disk.device} - - {formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)} - -
- 0}> -
- -
-
+ {/* Memory */} +
+ + {/* Temperature */} + + + {/* Disk */} + + + {/* Uptime */} + + + + {/* Drawer - Additional Info */} + + + + + + + ); + }} + + +
handleSort('name')} > -
- {column.label} - {column.sortKey === sortKey() && ( - - {sortDirection() === 'asc' ? '▲' : '▼'} - - )} -
- - )} - - + Host {renderSortIndicator('name')} +
handleSort('platform')}> + Platform {renderSortIndicator('platform')} + handleSort('cpu')}> + CPU {renderSortIndicator('cpu')} + handleSort('memory')}> + Memory {renderSortIndicator('memory')} + + Temp + handleSort('disk')}> + Disk {renderSortIndicator('disk')} + handleSort('uptime')}> + Uptime {renderSortIndicator('uptime')} +
+
+ +
+

+ {host.displayName || host.hostname || host.id} +

+ +

+ {host.hostname} +

- 0}> -
- Load Avg - {host.loadAverage!.map(l => l.toFixed(2)).join(', ')} -
-
- -
- Arch - {host.architecture} -
-
- -
- Kernel - {host.kernelVersion} -
-
- -
- Agent - {host.agentVersion} -
+ +

+ Updated {formatRelativeTime(host.lastSeen!)} +

+
+
+

{host.platform || '—'}

+ +

+ {host.osName} {host.osVersion} +

+
+
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ —}> + + +
+
+ +
+ +
+
+ +
+
+ —} + > + + {formatUptime(host.uptimeSeconds!)} + + +
+
+
+
+ {/* System Info */} +
+
System
+
+ +
+ CPUs + {host.cpuCount}
- ); - }} - -
-
- + + 0}> +
+ Load Avg + {host.loadAverage!.map(l => l.toFixed(2)).join(', ')} +
+
+ +
+ Arch + {host.architecture} +
+
+ +
+ Kernel + {host.kernelVersion} +
+
+ +
+ Agent + {host.agentVersion} +
+
+
+
- {/* 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 ( + {/* Network Interfaces */} + 0}>
-
- 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)}% - -
- -
- Speed - {array.rebuildSpeed} +
Network
+
+ + {(iface) => ( +
+
{iface.name}
+ 0}> +
+ + {(addr) => ( + + {addr} + + )} + +
+
- - + )} +
- ); - }} - -
-
-
-
- - ); - }} -
- +
+ + {/* Disk Info */} + 0}> +
+
Disks
+
+ + {(disk) => { + const diskPercent = () => disk.usage ?? 0; + return ( +
+
+ {disk.mountpoint || disk.device} + + {formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)} + +
+ 0}> +
+ +
+
+
+ ); + }} +
+
+
+
+ + {/* 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)}% + +
+ +
+ Speed + {array.rebuildSpeed} +
+
+
+
+
+ ); + }} +
+
+ + +
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 2e0d005..a3b65e0 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -10,92 +10,11 @@ import { useAlertsActivation } from '@/stores/alertsActivation'; import { buildMetricKey } from '@/utils/metricsKeys'; import { StatusDot } from '@/components/shared/StatusDot'; import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status'; -import { type ColumnPriority } from '@/hooks/useBreakpoint'; -import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive'; +import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; - -// Icons for mobile headers -const ClockIcon = (props: { class?: string }) => ( - - - -); - -const CpuIcon = (props: { class?: string }) => ( - - - -); - -const MemoryIcon = (props: { class?: string }) => ( - - - -); - -const DiskIcon = (props: { class?: string }) => ( - - - -); - -const TempIcon = (props: { class?: string }) => ( - - - - -); - -const VMIcon = (props: { class?: string }) => ( - - - -); - -const ContainerIcon = (props: { class?: string }) => ( - - - -); - -const BackupIcon = (props: { class?: string }) => ( - - - -); - -// Column configuration using the new priority system -interface ColumnDef { - id: string; - label: string; - priority: ColumnPriority; - icon?: Component<{ class?: string }>; - minWidth?: string; - maxWidth?: string; - flex?: number; - align?: 'left' | 'center' | 'right'; -} - -const BASE_COLUMNS: ColumnDef[] = [ - { id: 'name', label: 'Node', priority: 'essential', minWidth: '100px', maxWidth: '300px', align: 'left' }, - { id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', flex: 1, align: 'center' }, - // Metric columns - fixed width to match progress bar max-width (140px + padding) - { id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, - { id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, - { id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, -]; - -const TEMP_COLUMN: ColumnDef = { - id: 'temperature', - label: 'Temp', - priority: 'essential', - icon: TempIcon, - minWidth: '35px', - flex: 1, - align: 'center' -}; +import { useBreakpoint } from '@/hooks/useBreakpoint'; interface NodeSummaryTableProps { nodes: Node[]; @@ -114,6 +33,7 @@ export const NodeSummaryTable: Component = (props) => { const { activeAlerts, state } = useWebSocket(); const alertsActivation = useAlertsActivation(); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); + const { isMobile } = useBreakpoint(); const isTemperatureMonitoringEnabled = (node: Node): boolean => { const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; @@ -140,36 +60,9 @@ export const NodeSummaryTable: Component = (props) => { | 'temperature' | CountSortKey; - interface CountColumn { - header: string; - key: CountSortKey; - icon: Component<{ class?: string }>; - minWidth?: string; - maxWidth?: string; - } - const [sortKey, setSortKey] = createSignal('default'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); - const countColumns = createMemo(() => { - switch (props.currentTab) { - case 'dashboard': - return [ - { header: 'VMs', key: 'vmCount', icon: VMIcon, minWidth: '40px' }, - { header: 'CTs', key: 'containerCount', icon: ContainerIcon, minWidth: '40px' }, - ]; - case 'storage': - return [ - { header: 'Storage', key: 'storageCount', icon: DiskIcon, minWidth: '50px' }, - { header: 'Disks', key: 'diskCount', icon: DiskIcon, minWidth: '45px' }, - ]; - case 'backups': - return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon, minWidth: '50px' }]; - default: - return []; - } - }); - const hasAnyTemperatureData = createMemo(() => { return ( props.nodes?.some( @@ -178,34 +71,6 @@ export const NodeSummaryTable: Component = (props) => { ); }); - // Build dynamic columns list - const columns = createMemo(() => { - const cols = [...BASE_COLUMNS]; - - if (hasAnyTemperatureData()) { - cols.push(TEMP_COLUMN); - } - - // Add count columns based on tab - these share extra space evenly with other flex columns - countColumns().forEach((cc) => { - cols.push({ - id: cc.key, - label: cc.header, - priority: 'essential' as ColumnPriority, - icon: cc.icon, - minWidth: cc.minWidth || '40px', - flex: 1, - align: 'center', - }); - }); - - return cols; - }); - - // Use the responsive grid template hook for dynamic column visibility - // Pass the columns accessor to support reactive column changes - const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns }); - const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`; const vmCountsByNode = createMemo>(() => { @@ -465,225 +330,221 @@ export const NodeSummaryTable: Component = (props) => { }); }); - // Header cell component - const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => { - const Icon = cellProps.column.icon; - const isSorted = () => sortKey() === cellProps.column.id; - const sortIndicator = () => isSorted() ? (sortDirection() === 'asc' ? '▲' : '▼') : ''; - - const alignClass = () => { - if (cellProps.column.align === 'center') return 'justify-center text-center'; - if (cellProps.column.align === 'right') return 'justify-end text-right'; - return 'justify-start text-left'; - }; - - const baseClass = `px-0.5 md:px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full ${alignClass()}`; - const nameClass = cellProps.column.id === 'name' ? 'pl-3' : ''; - - return ( -
handleSort(cellProps.column.id as Exclude)} - onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude)} - tabindex="0" - role="button" - aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - > - - - {Icon && } - - - - {cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label} - - - {sortIndicator()} - -
- ); + const renderSortIndicator = (key: SortKey) => { + if (sortKey() !== key) return null; + return sortDirection() === 'asc' ? '▲' : '▼'; }; + const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; + return ( -
- {/* Header */} -
- - {(column) => ( - - )} - -
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {(item) => { + const isPVEItem = isPVE(item); + const isPBSItem = !isPVEItem; + const node = isPVEItem ? (item as Node) : null; + const pbs = isPBSItem ? (item as PBSInstance) : null; -
- - {(item) => { - const isPVEItem = isPVE(item); - const isPBSItem = !isPVEItem; - const node = isPVEItem ? (item as Node) : null; - const pbs = isPBSItem ? (item as PBSInstance) : null; + const online = isItemOnline(item); + const statusIndicator = createMemo(() => + isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance), + ); + const cpuPercentValue = getCpuPercent(item); + const memoryPercentValue = getMemoryPercent(item); + const diskPercentValue = getDiskPercent(item); + const diskSublabel = getDiskSublabel(item); + const cpuTemperatureValue = getCpuTemperatureValue(item); + const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0; + const displayName = () => { + if (isPVEItem) return getNodeDisplayName(node as Node); + return (pbs as PBSInstance).name; + }; + const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node); - const online = isItemOnline(item); - const statusIndicator = createMemo(() => - isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance), - ); - const cpuPercentValue = getCpuPercent(item); - const memoryPercentValue = getMemoryPercent(item); - const diskPercentValue = getDiskPercent(item); - const diskSublabel = getDiskSublabel(item); - const cpuTemperatureValue = getCpuTemperatureValue(item); - const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0; - const displayName = () => { - if (isPVEItem) return getNodeDisplayName(node as Node); - return (pbs as PBSInstance).name; - }; - const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node); + const nodeId = isPVEItem ? node!.id : pbs!.name; + const isSelected = () => props.selectedNode === nodeId; + const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name; + const metricsKey = buildMetricKey('node', resourceId); + const alertStyles = createMemo(() => + getAlertStyles(resourceId, activeAlerts, alertsEnabled()), + ); + const showAlertHighlight = createMemo( + () => alertStyles().hasUnacknowledgedAlert && online, + ); - const nodeId = isPVEItem ? node!.id : pbs!.name; - const isSelected = () => props.selectedNode === nodeId; - const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name; - const metricsKey = buildMetricKey('node', resourceId); - const alertStyles = createMemo(() => - getAlertStyles(resourceId, activeAlerts, alertsEnabled()), - ); - const showAlertHighlight = createMemo( - () => alertStyles().hasUnacknowledgedAlert && online, - ); + const rowStyle = createMemo(() => { + const styles: Record = {}; + const shadows: string[] = []; - const rowStyle = createMemo(() => { - const styles: Record = {}; - const shadows: string[] = []; + if (showAlertHighlight()) { + const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; + shadows.push(`inset 4px 0 0 0 ${color}`); + } - if (showAlertHighlight()) { - const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; - shadows.push(`inset 4px 0 0 0 ${color}`); - } + if (isSelected()) { + shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); + shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); + } - if (isSelected()) { - shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); - shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); - } + if (shadows.length > 0) { + styles['box-shadow'] = shadows.join(', '); + } - if (shadows.length > 0) { - styles['box-shadow'] = shadows.join(', '); - } + return styles; + }); - return styles; - }); + const rowClass = createMemo(() => { + const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; - const rowClass = createMemo(() => { - const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; + if (isSelected()) { + return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`; + } - if (isSelected()) { - return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`; - } + if (showAlertHighlight()) { + const alertBg = alertStyles().severity === 'critical' + ? 'bg-red-50 dark:bg-red-950/30' + : 'bg-yellow-50 dark:bg-yellow-950/20'; + return `cursor-pointer transition-all duration-200 relative hover:shadow-sm group ${alertBg}`; + } - if (showAlertHighlight()) { - return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; - } + let className = baseHover; - let className = baseHover; + if (props.selectedNode && props.selectedNode !== nodeId) { + className += ' opacity-50 hover:opacity-80'; + } - if (props.selectedNode && props.selectedNode !== nodeId) { - className += ' opacity-50 hover:opacity-80'; - } + if (!online) { + className += ' opacity-60'; + } - if (!online) { - className += ' opacity-60'; - } + return className; + }); - return className; - }); - - const cellBgClass = createMemo(() => { - if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30'; - if (showAlertHighlight()) { - return alertStyles().severity === 'critical' - ? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40' - : 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30'; - } - return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50'; - }); - - // Render cell content based on column type - const renderCell = (column: ColumnDef) => { - const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`; - const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start'; - - switch (column.id) { - case 'name': - return ( -
props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} + > + {/* Name */} + - case 'uptime': - return ( -
+ {/* Uptime */} +
- case 'cpu': - return ( -
- -
- -
-
-
+ + {/* Memory */} + - case 'disk': - return ( -
- -
- ); + {/* Disk */} + - case 'temperature': - return ( -
- -} - > - {(() => { - const value = cpuTemperatureValue as number; - const temp = node!.temperature; - const cpuMinValue = - typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null; - const cpuMaxValue = - typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0 - ? temp.cpuMaxRecord - : null; - const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null; + {/* Temperature */} + +
+ - return ( -
props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} - > - - {(column) => renderCell(column)} - -
- ); - }} - - - - + {/* Dashboard tab: VMs and CTs */} + + + + + + {/* Storage tab: Storage and Disks */} + + + + + + {/* Backups tab: Backups */} + + + + + ); + }} + + +
handleSort('name')} + > + {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')} + handleSort('uptime')}> + Uptime {renderSortIndicator('uptime')} + handleSort('cpu')}> + CPU {renderSortIndicator('cpu')} + handleSort('memory')}> + Memory {renderSortIndicator('memory')} + handleSort('disk')}> + Disk {renderSortIndicator('disk')} + handleSort('temperature')}> + Temp {renderSortIndicator('temperature')} + handleSort('vmCount')}> + VMs {renderSortIndicator('vmCount')} + handleSort('containerCount')}> + CTs {renderSortIndicator('containerCount')} + handleSort('storageCount')}> + Storage {renderSortIndicator('storageCount')} + handleSort('diskCount')}> + Disks {renderSortIndicator('diskCount')} + handleSort('backupCount')}> + Backups {renderSortIndicator('backupCount')} +
+
+ + e.stopPropagation()} + class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 whitespace-nowrap" + title={displayName()} + > + {displayName()} + + + + ({(node as Node).name}) + + +
- ); +
+
= (props) => {
- ); +
+ +
+
- - ); - - case 'memory': - return ( -
- -
- -
+
+
+ +
+
+
+ - ); +
+ + +
+ -} + > + {(() => { + const value = cpuTemperatureValue as number; + const temp = node!.temperature; + const cpuMinValue = + typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null; + const cpuMaxValue = + typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0 + ? temp.cpuMaxRecord + : null; + const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null; - const gpus = temp?.gpu ?? []; - const hasGPU = gpus.length > 0; + const gpus = temp?.gpu ?? []; + const hasGPU = gpus.length > 0; - if (hasMinMax || hasGPU) { - const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined; - const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined; + if (hasMinMax || hasGPU) { + const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined; + const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined; + + return ( +
`${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}> + +
+ ); + } return ( -
`${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}> - -
+ ); - } - - return ( - - ); - })()} -
-
- ); - - // Count columns (vmCount, containerCount, storageCount, diskCount, backupCount) - default: - if (column.id.endsWith('Count')) { - const value = getCountValue(item, column.id as CountSortKey); - const display = online ? value ?? '-' : '-'; - const textClass = online - ? 'text-xs text-gray-700 dark:text-gray-300' - : 'text-xs text-gray-400 dark:text-gray-500'; - return ( -
- {display} + })()} +
- ); - } - return null; - } - }; +
+
+ + {online ? getCountValue(item, 'vmCount') ?? '-' : '-'} + +
+
+
+ + {online ? getCountValue(item, 'containerCount') ?? '-' : '-'} + +
+
+
+ + {online ? getCountValue(item, 'storageCount') ?? '-' : '-'} + +
+
+
+ + {online ? getCountValue(item, 'diskCount') ?? '-' : '-'} + +
+
+
+ + {online ? getCountValue(item, 'backupCount') ?? '-' : '-'} + +
+
+
+ ); }; diff --git a/frontend-modern/src/components/shared/TemperatureGauge.tsx b/frontend-modern/src/components/shared/TemperatureGauge.tsx index bbb3b18..ca43e14 100644 --- a/frontend-modern/src/components/shared/TemperatureGauge.tsx +++ b/frontend-modern/src/components/shared/TemperatureGauge.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, Show } from 'solid-js'; +import { Component, createMemo } from 'solid-js'; interface TemperatureGaugeProps { value: number; @@ -6,7 +6,6 @@ interface TemperatureGaugeProps { max?: number | null; critical?: number; warning?: number; - label?: string; class?: string; } @@ -14,54 +13,15 @@ export const TemperatureGauge: Component = (props) => { const critical = props.critical ?? 80; const warning = props.warning ?? 70; - // Calculate percentage (assuming 0-100°C range for simplicity, or slightly dynamic) - // Most CPUs idle around 30-40, max out at 100. - const percent = createMemo(() => Math.min(100, Math.max(0, props.value))); - - const colorClass = createMemo(() => { - if (props.value >= critical) return 'bg-red-500 dark:bg-red-500'; - if (props.value >= warning) return 'bg-yellow-500 dark:bg-yellow-500'; - return 'bg-green-500 dark:bg-green-500'; - }); - const textColorClass = createMemo(() => { - if (props.value >= critical) return 'text-red-700 dark:text-red-400'; - if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400'; - return 'text-green-700 dark:text-green-400'; + if (props.value >= critical) return 'text-red-600 dark:text-red-400'; + if (props.value >= warning) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-gray-600 dark:text-gray-400'; }); return ( -
- {/* Text Value */} - - {Math.round(props.value)}°C - - - {/* Bar */} -
-
- - {/* Min Marker */} - -
- - - {/* Max Marker */} - -
- -
-
+ + {Math.round(props.value)}°C + ); }; diff --git a/frontend-modern/src/components/shared/responsive/useGridTemplate.ts b/frontend-modern/src/components/shared/responsive/useGridTemplate.ts index a46fd80..8761c79 100644 --- a/frontend-modern/src/components/shared/responsive/useGridTemplate.ts +++ b/frontend-modern/src/components/shared/responsive/useGridTemplate.ts @@ -11,6 +11,9 @@ export interface GridTemplateOptions { /** Custom gap between columns (CSS value) */ gap?: string; + + /** If true, show all columns regardless of breakpoint (use horizontal scroll instead) */ + disableColumnHiding?: boolean; } export interface GridTemplateResult { @@ -38,6 +41,16 @@ function generateGridTemplate(columns: ColumnConfig[]): string { const min = col.minWidth || '50px'; const max = col.maxWidth || `${col.flex || 1}fr`; + // If both min and max are 'auto', use just 'auto' for content-based sizing + if (min === 'auto' && max === 'auto') { + return 'auto'; + } + + // If only max is 'auto', use minmax with auto + if (max === 'auto') { + return `minmax(${min}, auto)`; + } + // If we have both min and max as fixed values, use the appropriate one if (col.maxWidth && !col.maxWidth.includes('fr')) { return `minmax(${min}, ${col.maxWidth})`; @@ -100,7 +113,11 @@ export function useGridTemplate(options: GridTemplateOptions): GridTemplateResul }; const visibleColumns = createMemo(() => { - return getVisibleColumns(getColumns(), breakpoint()); + const cols = getColumns(); + if (options.disableColumnHiding) { + return cols; + } + return getVisibleColumns(cols, breakpoint()); }); const gridTemplate = createMemo(() => {