diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 80832d9..9c8df7f 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -925,111 +925,93 @@ export function Dashboard(props: DashboardProps) { {/* Table View */} 0}> - - {/* Desktop Header - Hidden on mobile/tablet, visible on xl+ */} -
- {/* Name Header - Sticky on mobile */} + +
+ {/* Desktop Header */} +
+ {/* Name Header */}
handleSort('name')} > Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
- {/* Metrics Headers - Scrollable on mobile */} -
-
-
handleSort('type')} - > - - - - - {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('vmid')} - > - - - - - {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('uptime')} - > - - - - - {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('cpu')} - > - - - - - {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('memory')} - > - - - - - {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('disk')} - > - - - - - {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} -
- - - - -
+ {/* Type */} +
handleSort('type')} + > + Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* VMID */} +
handleSort('vmid')} + > + ID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Uptime */} +
handleSort('uptime')} + > + Up {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* CPU */} +
handleSort('cpu')} + > + CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Memory */} +
handleSort('memory')} + > + Mem {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Disk */} +
handleSort('disk')} + > + Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Disk Read */} +
handleSort('diskRead')} + > + D Rd {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Disk Write */} +
handleSort('diskWrite')} + > + D Wr {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Net In */} +
handleSort('networkIn')} + > + N In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ {/* Net Out */} +
handleSort('networkOut')} + > + N Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
{/* Guest List */} -
+
{ const nodeA = nodeByInstance()[instanceIdA]; @@ -1076,6 +1058,7 @@ export function Dashboard(props: DashboardProps) { }}
+
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index e97562f..f3bc695 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,9 +1,7 @@ import { GuestDrawer } from './GuestDrawer'; -import { createMemo, createSignal, createEffect, Show } from 'solid-js'; +import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; import type { VM, Container } from '@/types/api'; -import { formatBytes, formatPercent, formatUptime } from '@/utils/format'; -import { MetricBar } from './MetricBar'; -import { IOMetric } from './IOMetric'; +import { formatBytes, formatUptime, formatSpeed } from '@/utils/format'; import { TagBadges } from './TagBadges'; import { StatusDot } from '@/components/shared/StatusDot'; @@ -12,6 +10,8 @@ import { GuestMetadataAPI } from '@/api/guestMetadata'; 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'; type Guest = VM | Container; @@ -37,6 +37,31 @@ const isVM = (guest: Guest): guest is VM => { return guest.type === 'qemu'; }; +// Column configuration using the priority system +interface ColumnDef { + id: string; + label: string; + priority: ColumnPriority; + minWidth?: string; + maxWidth?: string; + flex?: number; +} + +const GUEST_COLUMNS: ColumnDef[] = [ + { id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', flex: 1.5 }, + { id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '32px' }, + { id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '36px' }, + { id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '50px' }, + { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1 }, + { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', flex: 1 }, + { id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1 }, + // I/O columns - fixed width, no flex + { id: 'diskRead', label: 'D Read', priority: 'essential', minWidth: '44px', maxWidth: '54px' }, + { id: 'diskWrite', label: 'D Write', priority: 'essential', minWidth: '44px', maxWidth: '54px' }, + { id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '44px', maxWidth: '54px' }, + { id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '44px', maxWidth: '54px' }, +]; + interface GuestRowProps { guest: Guest; alertStyles?: { @@ -65,6 +90,9 @@ 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 }); + // Create namespaced metrics key const metricsKey = createMemo(() => { const kind = props.guest.type === 'qemu' ? 'vm' : 'container'; @@ -108,12 +136,9 @@ export function GuestRow(props: GuestRowProps) { } }); - - const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100); const memPercent = createMemo(() => { if (!props.guest.memory) return 0; - // Use the pre-calculated usage percentage from the backend return props.guest.memory.usage || 0; }); const memoryUsageLabel = createMemo(() => { @@ -166,17 +191,14 @@ export function GuestRow(props: GuestRowProps) { if (target.closest('a, button, input, [data-prevent-toggle]')) { return; } - // Toggle: if this guest is currently expanded, close it; otherwise open it (closing any other) setCurrentlyExpandedGuestId(prev => prev === guestId() ? null : guestId()); }; const startEditingUrl = (event: MouseEvent) => { event.stopPropagation(); - // If another guest is being edited, save it first const currentEditing = currentlyEditingGuestId(); if (currentEditing !== null && currentEditing !== guestId()) { - // Find the input for the currently editing guest and blur it const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement; if (currentInput) { currentInput.blur(); @@ -188,7 +210,6 @@ export function GuestRow(props: GuestRowProps) { setCurrentlyEditingGuestId(guestId()); }; - // Auto-focus the input when editing starts createEffect(() => { if (isEditingUrl() && urlInputRef) { urlInputRef.focus(); @@ -196,21 +217,16 @@ export function GuestRow(props: GuestRowProps) { } }); - // Track if we're currently editing to prevent cleanup during re-renders let isCurrentlyMounted = true; - // Add global click handler to close editor and prevent clicks while editing createEffect(() => { if (isEditingUrl() && isCurrentlyMounted) { const handleGlobalClick = (e: MouseEvent) => { - // Double-check we're still the editing guest if (currentlyEditingGuestId() !== guestId()) return; const target = e.target as HTMLElement; - // Allow clicking another guest name to switch editing const isClickingGuestName = target.closest('[data-guest-name-editable]'); - // If clicking outside the editor (and not another guest name), close it and prevent the click if (!target.closest('[data-url-editor]') && !isClickingGuestName) { e.preventDefault(); e.stopPropagation(); @@ -220,7 +236,6 @@ export function GuestRow(props: GuestRowProps) { }; const handleGlobalMouseDown = (e: MouseEvent) => { - // Double-check we're still the editing guest if (currentlyEditingGuestId() !== guestId()) return; const target = e.target as HTMLElement; @@ -233,7 +248,6 @@ export function GuestRow(props: GuestRowProps) { } }; - // Use capture phase to intercept clicks before they bubble document.addEventListener('mousedown', handleGlobalMouseDown, true); document.addEventListener('click', handleGlobalClick, true); return () => { @@ -244,23 +258,19 @@ export function GuestRow(props: GuestRowProps) { }); const saveUrl = async () => { - // Only save if this guest is the one being edited if (currentlyEditingGuestId() !== guestId()) return; const newUrl = (editingValues.get(guestId()) || '').trim(); - // Clear global editing state editingValues.delete(guestId()); setEditingValuesVersion(v => v + 1); setCurrentlyEditingGuestId(null); - // If URL hasn't changed, don't save if (newUrl === (customUrl() || '')) return; try { await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); - // Animate if transitioning from no URL to having a URL const hadUrl = !!customUrl(); if (!hadUrl && newUrl) { setShouldAnimateIcon(true); @@ -269,7 +279,6 @@ export function GuestRow(props: GuestRowProps) { setCustomUrl(newUrl || undefined); - // Notify parent to update metadata if (props.onCustomUrlUpdate) { props.onCustomUrlUpdate(guestId(), newUrl); } @@ -286,21 +295,17 @@ export function GuestRow(props: GuestRowProps) { }; const deleteUrl = async () => { - // Only process if this guest is the one being edited if (currentlyEditingGuestId() !== guestId()) return; - // Clear global editing state editingValues.delete(guestId()); setEditingValuesVersion(v => v + 1); setCurrentlyEditingGuestId(null); - // If there was a URL set, delete it if (customUrl()) { try { await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' }); setCustomUrl(undefined); - // Notify parent to update metadata if (props.onCustomUrlUpdate) { props.onCustomUrlUpdate(guestId(), ''); } @@ -314,17 +319,15 @@ export function GuestRow(props: GuestRowProps) { }; const cancelEditingUrl = () => { - // Only cancel if this guest is the one being edited if (currentlyEditingGuestId() !== guestId()) return; - // Just close without saving editingValues.delete(guestId()); setEditingValuesVersion(v => v + 1); setCurrentlyEditingGuestId(null); }; + const diskPercent = createMemo(() => { if (!props.guest.disk || props.guest.disk.total === 0) return 0; - // Check if usage is -1 (unknown/no guest agent) if (props.guest.disk.usage === -1) return -1; return (props.guest.disk.used / props.guest.disk.total) * 100; }); @@ -339,7 +342,6 @@ export function GuestRow(props: GuestRowProps) { const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline())); const lockLabel = createMemo(() => (props.guest.lock || '').trim()); - // Get helpful tooltip for disk status const getDiskStatusTooltip = () => { if (!isVM(props.guest)) return 'Disk stats unavailable'; @@ -382,9 +384,6 @@ export function GuestRow(props: GuestRowProps) { return '#9ca3af'; }); - - - // Get row styling - include alert styles if present const rowClass = createMemo(() => { const base = 'transition-all duration-200 relative'; const hover = 'hover:shadow-sm'; @@ -404,9 +403,6 @@ export function GuestRow(props: GuestRowProps) { return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`; }); - - - // Get row styles including box-shadow for alert border const rowStyle = createMemo(() => { if (!showAlertHighlight()) return {}; const color = alertAccentColor(); @@ -416,265 +412,287 @@ export function GuestRow(props: GuestRowProps) { }; }); + // Render cell content based on column type + const renderCell = (column: ColumnDef) => { + switch (column.id) { + case 'name': + return ( +
+
+
+ +
+ + + {props.guest.name} + + + event.stopPropagation()} + > + + + + + +
+ } + > +
+ { + 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="flex-1 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 ( +
+ + {isVM(props.guest) ? 'VM' : 'LXC'} + +
+ ); + + case 'vmid': + return ( +
+ {props.guest.vmid} +
+ ); + + case 'uptime': + return ( +
+
+ + + {formatUptime(props.guest.uptime, true)} + + +
+
+ ); + + case 'cpu': + return ( +
+ +
+ ); + + case 'memory': + return ( +
+
+ +
+
+ ); + + case 'disk': + return ( +
+ + - + + } + > + + +
+ ); + + case 'diskRead': + return ( +
+ -}> + {formatSpeed(props.guest.diskRead)} + +
+ ); + + case 'diskWrite': + return ( +
+ -}> + {formatSpeed(props.guest.diskWrite)} + +
+ ); + + case 'netIn': + return ( +
+ -}> + {formatSpeed(props.guest.networkIn)} + +
+ ); + + case 'netOut': + return ( +
+ -}> + {formatSpeed(props.guest.networkOut)} + +
+ ); + + default: + return null; + } + }; + return ( <>
- {/* Name Section - Sticky on mobile */} -
-
-
- -
- - - {props.guest.name} - - - event.stopPropagation()} - > - - - - - -
- } - > -
- { - 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="flex-1 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()} - - -
-
- - {/* Metrics Section - Scrollable on mobile */} -
-
- {/* Type */} -
- - {isVM(props.guest) ? 'VM' : 'LXC'} - -
- - {/* VMID */} -
- {props.guest.vmid} -
- - {/* Uptime */} -
-
- - {formatUptime(props.guest.uptime, true)} - - -
-
- - {/* CPU */} -
- -}> -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(cpuPercent())} -
- -
-
- - {/* Memory */} -
-
- -}> -
= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memPercent() >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(memPercent())} -
- -
-
-
- - {/* Disk */} -
- - - - - } - > -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(diskPercent())} -
- -
-
- - {/* Disk I/O */} - - - - {/* Network I/O */} - - -
-
+ + {(column) => renderCell(column)} +
@@ -690,4 +708,4 @@ export function GuestRow(props: GuestRowProps) { ); -}; +} diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index 8ce0ff1..58adca8 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -1,7 +1,6 @@ import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { DockerHost } from '@/types/api'; import { Card } from '@/components/shared/Card'; -import { MetricBar } from '@/components/Dashboard/MetricBar'; import { renderDockerStatusBadge } from './DockerStatusBadge'; import { resolveHostRuntime } from './runtimeDisplay'; import { formatPercent, formatUptime } from '@/utils/format'; @@ -9,6 +8,8 @@ import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { buildMetricKey } from '@/utils/metricsKeys'; import { StatusDot } from '@/components/shared/StatusDot'; import { getDockerHostStatusIndicator } from '@/utils/status'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { ResponsiveMetricCell } from '@/components/shared/responsive'; export interface DockerHostSummary { host: DockerHost; @@ -47,6 +48,7 @@ const getDisplayName = (host: DockerHost) => { export const DockerHostSummaryTable: Component = (props) => { const [sortKey, setSortKey] = createSignal('name'); const [sortDirection, setSortDirection] = createSignal('asc'); + const { isMobile } = useBreakpoint(); const handleSort = (key: SortKey) => { if (sortKey() === key) { @@ -328,51 +330,33 @@ export const DockerHostSummaryTable: Component = (p
- —}> -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.cpuPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(summary.cpuPercent)} -
- -
+ - —}> -
= 85 ? 'text-red-600 dark:text-red-400 font-bold' : summary.memoryPercent >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(summary.memoryPercent)} -
- -
+ - —}> -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.diskPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(summary.diskPercent)} -
- -
+
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 75e0165..fb57dd6 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -1,7 +1,6 @@ import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api'; -import { formatBytes, formatPercent, formatUptime } from '@/utils/format'; -import { MetricBar } from '@/components/Dashboard/MetricBar'; +import { formatBytes, formatUptime } from '@/utils/format'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { Card } from '@/components/shared/Card'; @@ -11,6 +10,88 @@ 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, useGridTemplate } from '@/components/shared/responsive'; + +// 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: '70px', flex: 1.5, align: 'left' }, + { id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', maxWidth: '70px', align: 'center' }, + { id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', flex: 1.2, align: 'center' }, + { id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', flex: 1.2, align: 'center' }, + { id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', flex: 1.2, align: 'center' }, +]; + +const TEMP_COLUMN: ColumnDef = { + id: 'temperature', + label: 'Temp', + priority: 'essential', + icon: TempIcon, + minWidth: '35px', + maxWidth: '55px', + align: 'center' +}; interface NodeSummaryTableProps { nodes: Node[]; @@ -32,7 +113,6 @@ export const NodeSummaryTable: Component = (props) => { const isTemperatureMonitoringEnabled = (node: Node): boolean => { const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; - // Check per-node setting first, fall back to global if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) { return node.temperatureMonitoringEnabled; } @@ -58,6 +138,7 @@ export const NodeSummaryTable: Component = (props) => { interface CountColumn { header: string; key: CountSortKey; + icon: Component<{ class?: string }>; } const [sortKey, setSortKey] = createSignal('default'); @@ -67,23 +148,22 @@ export const NodeSummaryTable: Component = (props) => { switch (props.currentTab) { case 'dashboard': return [ - { header: 'VMs', key: 'vmCount' }, - { header: 'Containers', key: 'containerCount' }, + { header: 'VMs', key: 'vmCount', icon: VMIcon }, + { header: 'CTs', key: 'containerCount', icon: ContainerIcon }, ]; case 'storage': return [ - { header: 'Storage', key: 'storageCount' }, - { header: 'Disks', key: 'diskCount' }, + { header: 'Storage', key: 'storageCount', icon: DiskIcon }, + { header: 'Disks', key: 'diskCount', icon: DiskIcon }, ]; case 'backups': - return [{ header: 'Backups', key: 'backupCount' }]; + return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon }]; default: return []; } }); const hasAnyTemperatureData = createMemo(() => { - // Show temperature column if ANY node has monitoring enabled OR has temperature data return ( props.nodes?.some( (node) => node.temperature?.available || isTemperatureMonitoringEnabled(node), @@ -91,6 +171,34 @@ 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 + countColumns().forEach((cc) => { + cols.push({ + id: cc.key, + label: cc.header, + priority: 'essential' as ColumnPriority, + icon: cc.icon, + minWidth: '40px', + maxWidth: '50px', + 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>(() => { @@ -322,7 +430,6 @@ export const NodeSummaryTable: Component = (props) => { return aStr < bStr ? -1 : 1; }; - // Combine and sort nodes based on current sort selection const sortedItems = createMemo(() => { const items: SortableItem[] = []; @@ -349,377 +456,292 @@ export const NodeSummaryTable: Component = (props) => { }); }); - const gridTemplate = createMemo(() => { - const parts = ['minmax(90px, 1.5fr)']; // Name - parts.push('minmax(45px, 80px)'); // Uptime - parts.push('minmax(55px, 1.5fr)'); // CPU - parts.push('minmax(55px, 1.5fr)'); // Memory - parts.push('minmax(55px, 1.5fr)'); // Disk + // 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' ? '▲' : '▼') : ''; - if (hasAnyTemperatureData()) { - parts.push('minmax(45px, 65px)'); // Temp - } + 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'; + }; - countColumns().forEach(() => { - parts.push('minmax(40px, 85px)'); // Counts - }); + 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 parts.join(' '); - }); - - // Don't return null - let the table render even if empty - // This prevents the table from disappearing on refresh while data loads + 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()} + +
+ ); + }; return ( - <> - -
- {/* Header */} -
-
handleSort('name')} - onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} - tabindex="0" - role="button" - aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : '' - }`} - > - {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '} - {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('uptime')} - > - - - - - {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('cpu')} - > - - - - - {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('memory')} - > - - - - - {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
handleSort('disk')} - > - - - - - {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} -
- -
handleSort('temperature')} - > - - - - - {sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
- - {(column) => ( -
handleSort(column.key)} - > - - - - - - - - - - - - - - - - {sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')} -
- )} -
-
+ +
+ {/* Header */} +
+ + {(column, index) => ( + + )} + +
-
- - {(item) => { - const isPVE = item.type === 'pve'; - const isPBS = item.type === 'pbs'; - const node = isPVE ? (item.data as Node) : null; - const pbs = isPBS ? (item.data as PBSInstance) : null; +
+ + {(item) => { + const isPVE = item.type === 'pve'; + const isPBS = item.type === 'pbs'; + const node = isPVE ? (item.data as Node) : null; + const pbs = isPBS ? (item.data as PBSInstance) : null; - const online = isItemOnline(item); - const statusIndicator = createMemo(() => - isPVE ? 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 = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0; - const displayName = () => { - if (isPVE) return getNodeDisplayName(node as Node); - return (pbs as PBSInstance).name; - }; - const showActualName = () => isPVE && hasAlternateDisplayName(node as Node); + const online = isItemOnline(item); + const statusIndicator = createMemo(() => + isPVE ? 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 = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0; + const displayName = () => { + if (isPVE) return getNodeDisplayName(node as Node); + return (pbs as PBSInstance).name; + }; + const showActualName = () => isPVE && hasAlternateDisplayName(node as Node); - // Use unique node ID (not hostname) to handle duplicate node names - const nodeId = isPVE ? node!.id : pbs!.name; - const isSelected = () => props.selectedNode === nodeId; - // Use the full resource ID for alert matching - const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name; - // Use namespaced metric key for sparklines - const metricsKey = buildMetricKey('node', resourceId); - const alertStyles = createMemo(() => - getAlertStyles(resourceId, activeAlerts, alertsEnabled()), - ); - const showAlertHighlight = createMemo( - () => alertStyles().hasUnacknowledgedAlert && online, - ); + const nodeId = isPVE ? node!.id : pbs!.name; + const isSelected = () => props.selectedNode === nodeId; + const resourceId = isPVE ? 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`; - } + if (isSelected()) { + return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`; + } - if (showAlertHighlight()) { - return alertStyles().severity === 'critical' - ? 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group' - : 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; - } + 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'; - }); + 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'; + }); - return ( -
props.onNodeClick(nodeId, item.type)} - > -
-
- - e.stopPropagation()} - class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate" - title={displayName()} - > - {displayName()} - - - - ({(node as Node).name}) - - - -
- - + - {formatUptime(uptimeValue, true)} - - - -
-
- -} - > -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(cpuPercentValue ?? 0)} -
- + ); + + case 'cpu': + return ( +
+ +
+ ); + + case 'memory': + return ( +
+ -
-
-
-
- -} - > -
= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memoryPercentValue! >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(memoryPercentValue ?? 0)} -
- -
-
-
- -} - > -
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> - {formatPercent(diskPercentValue ?? 0)} -
- -
-
- -
+ } + isRunning={online} + showMobile={isMobile()} + class="w-full" + /> +
+ ); + + case 'disk': + return ( +
+ +
+ ); + + case 'temperature': + return ( +
= (props) => { (node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) && isTemperatureMonitoringEnabled(node!) } - fallback={ - - - } + fallback={-} > {(() => { const value = cpuTemperatureValue as number; @@ -764,11 +784,11 @@ export const NodeSummaryTable: Component = (props) => { }; return ( - + {value}°C - - - {(column) => { - const value = getCountValue(item, column.key); - 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} -
- ); - }} -
-
- ); - }} - -
+ ); + + // 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; + } + }; + + return ( +
props.onNodeClick(nodeId, item.type)} + > + + {(column) => renderCell(column)} + +
+ ); + }} +
- - +
+ ); }; diff --git a/frontend-modern/src/components/shared/OnlineStatusBadge.tsx b/frontend-modern/src/components/shared/OnlineStatusBadge.tsx new file mode 100644 index 0000000..f5c2ec0 --- /dev/null +++ b/frontend-modern/src/components/shared/OnlineStatusBadge.tsx @@ -0,0 +1,29 @@ +import { Component } from 'solid-js'; +import type { StatusIndicatorVariant } from '@/utils/status'; + +interface OnlineStatusBadgeProps { + variant: StatusIndicatorVariant; + label: string; + class?: string; +} + +const variantClasses: Record = { + success: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + muted: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400', +}; + +/** + * OnlineStatusBadge - A pill-shaped badge showing online/offline/degraded status + * Used in tables to show resource status (nodes, guests, containers, etc.) + */ +export const OnlineStatusBadge: Component = (props) => { + return ( + + {props.label} + + ); +}; diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx new file mode 100644 index 0000000..c04141c --- /dev/null +++ b/frontend-modern/src/components/shared/responsive/ResponsiveHeader.tsx @@ -0,0 +1,127 @@ +import { Component, Show } from 'solid-js'; +import type { ColumnConfig, SortState } from '@/types/responsive'; + +export interface ResponsiveHeaderProps { + /** Column configuration */ + column: ColumnConfig; + + /** Current sort state */ + sortState?: SortState; + + /** Sort handler - called with the column's sortKey or id */ + onSort?: (key: string) => void; + + /** Whether to show mobile (icon) view */ + showMobile?: boolean; + + /** Additional CSS classes */ + class?: string; +} + +/** + * A responsive table header cell that shows an icon on mobile + * and the full label on larger screens. + * + * @example + * ```tsx + * + * ``` + */ +export const ResponsiveHeader: Component = (props) => { + const sortKey = () => props.column.sortKey || props.column.id; + const isSorted = () => props.sortState?.key === sortKey(); + const sortIndicator = () => { + if (!isSorted()) return null; + return props.sortState?.direction === 'asc' ? '▲' : '▼'; + }; + + const handleClick = () => { + if (props.column.sortable && props.onSort) { + props.onSort(sortKey()); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && props.column.sortable && props.onSort) { + props.onSort(sortKey()); + } + }; + + const alignmentClass = () => { + switch (props.column.align) { + case 'center': return 'justify-center text-center'; + case 'right': return 'justify-end text-right'; + default: return 'justify-start text-left'; + } + }; + + const baseClass = () => { + const classes = [ + 'px-0.5 md:px-2 py-1', + 'text-[11px] sm:text-xs font-medium uppercase tracking-wider', + 'flex items-center h-full', + alignmentClass(), + ]; + + if (props.column.sortable) { + classes.push('cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600'); + } + + if (props.class) { + classes.push(props.class); + } + + return classes.join(' '); + }; + + const Icon = props.column.icon; + + return ( +
+ {/* Mobile: Show icon if available */} + + + {Icon && } + + + + {/* Desktop: Show label (or always if no icon) */} + + {props.column.label} + + + {/* Sort indicator */} + + {sortIndicator()} + +
+ ); +}; + +/** + * Sticky header variant for name columns + */ +export const StickyHeader: Component = (props) => { + return ( +
+ +
+ ); +}; diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx new file mode 100644 index 0000000..b9adb76 --- /dev/null +++ b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx @@ -0,0 +1,179 @@ +import { Component, Show, createMemo, JSX } from 'solid-js'; +import { MetricBar } from '@/components/Dashboard/MetricBar'; +import { formatPercent } from '@/utils/format'; + +export interface ResponsiveMetricCellProps { + /** Metric value (0-100 percentage) */ + value: number; + + /** Metric type for theming/thresholds */ + type: 'cpu' | 'memory' | 'disk'; + + /** Primary label (defaults to formatted percentage) */ + label?: string; + + /** Secondary label (e.g., "4.2GB / 8GB") */ + sublabel?: string; + + /** Resource ID for sparkline tracking */ + resourceId?: string; + + /** Whether the resource is running/online - if false, shows fallback */ + isRunning?: boolean; + + /** Show mobile (compact text) view */ + showMobile?: boolean; + + /** Custom fallback when not running (defaults to "—") */ + fallback?: JSX.Element; + + /** Additional CSS classes for container */ + class?: string; +} + +/** + * Get the appropriate text color class based on metric value and type + */ +function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): string { + // Thresholds match MetricBar component + if (type === 'cpu') { + if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold'; + if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium'; + return 'text-gray-600 dark:text-gray-400'; + } + + if (type === 'memory') { + if (value >= 85) return 'text-red-600 dark:text-red-400 font-bold'; + if (value >= 75) return 'text-orange-600 dark:text-orange-400 font-medium'; + return 'text-gray-600 dark:text-gray-400'; + } + + if (type === 'disk') { + if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold'; + if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium'; + return 'text-gray-600 dark:text-gray-400'; + } + + return 'text-gray-600 dark:text-gray-400'; +} + +/** + * A responsive metric cell that shows a simple colored percentage on mobile + * and a full MetricBar (with progress bar or sparkline) on desktop. + * + * @example + * ```tsx + * + * ``` + */ +export const ResponsiveMetricCell: Component = (props) => { + const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + const isRunning = () => props.isRunning !== false; // Default to true if not specified + + const defaultFallback = ( + + ); + + return ( + +
+ {/* Mobile: Colored percentage text */} + +
+ {displayLabel()} +
+
+ + {/* Desktop: Full MetricBar with sparkline support */} +
+ +
+
+
+ ); +}; + +/** + * Simpler metric text component for when you just want colored percentage + * without the MetricBar complexity + */ +export const MetricText: Component<{ + value: number; + type: 'cpu' | 'memory' | 'disk'; + label?: string; + class?: string; +}> = (props) => { + const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + + return ( + + {displayLabel()} + + ); +}; + +/** + * Metric cell with explicit mobile/desktop rendering + * Use this when you need full control over what renders in each mode + */ +export const DualMetricCell: Component<{ + value: number; + type: 'cpu' | 'memory' | 'disk'; + label?: string; + sublabel?: string; + resourceId?: string; + isRunning?: boolean; + showMobile: boolean; + mobileContent?: JSX.Element; + desktopContent?: JSX.Element; + fallback?: JSX.Element; + class?: string; +}> = (props) => { + const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + const isRunning = () => props.isRunning !== false; + + const defaultFallback = ( + + ); + + const defaultMobileContent = ( +
+ {displayLabel()} +
+ ); + + const defaultDesktopContent = ( + + ); + + return ( + +
+ + {props.mobileContent ?? defaultMobileContent} + +
+
+ ); +}; diff --git a/frontend-modern/src/components/shared/responsive/index.ts b/frontend-modern/src/components/shared/responsive/index.ts new file mode 100644 index 0000000..b0d4eed --- /dev/null +++ b/frontend-modern/src/components/shared/responsive/index.ts @@ -0,0 +1,11 @@ +// Responsive table components and utilities +// These provide a unified system for responsive column visibility across all tables + +export { ResponsiveHeader, StickyHeader } from './ResponsiveHeader'; +export { ResponsiveMetricCell, MetricText, DualMetricCell } from './ResponsiveMetricCell'; +export { useGridTemplate, generateStaticGridTemplate, generateResponsiveTemplates, getColumnVisibilityClass } from './useGridTemplate'; + +// Re-export types for convenience +export type { ResponsiveHeaderProps } from './ResponsiveHeader'; +export type { ResponsiveMetricCellProps } from './ResponsiveMetricCell'; +export type { GridTemplateOptions, GridTemplateResult } from './useGridTemplate'; diff --git a/frontend-modern/src/components/shared/responsive/useGridTemplate.ts b/frontend-modern/src/components/shared/responsive/useGridTemplate.ts new file mode 100644 index 0000000..a46fd80 --- /dev/null +++ b/frontend-modern/src/components/shared/responsive/useGridTemplate.ts @@ -0,0 +1,170 @@ +import { createMemo, Accessor } from 'solid-js'; +import type { ColumnConfig } from '@/types/responsive'; +import { useBreakpoint, type Breakpoint, PRIORITY_BREAKPOINTS } from '@/hooks/useBreakpoint'; + +export interface GridTemplateOptions { + /** Column configurations - can be a static array or reactive accessor */ + columns: ColumnConfig[] | Accessor; + + /** Whether to use a sticky first column on mobile */ + hasStickyColumn?: boolean; + + /** Custom gap between columns (CSS value) */ + gap?: string; +} + +export interface GridTemplateResult { + /** CSS grid-template-columns value */ + gridTemplate: Accessor; + + /** Columns that are currently visible */ + visibleColumns: Accessor; + + /** Check if a specific column is visible */ + isColumnVisible: (columnId: string) => boolean; + + /** Current breakpoint */ + breakpoint: Accessor; + + /** Whether currently in mobile view */ + isMobile: Accessor; +} + +/** + * Generate a CSS grid-template-columns value from column configs + */ +function generateGridTemplate(columns: ColumnConfig[]): string { + return columns.map(col => { + const min = col.minWidth || '50px'; + const max = col.maxWidth || `${col.flex || 1}fr`; + + // 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})`; + } + + // Use minmax for flexible columns + return `minmax(${min}, ${max})`; + }).join(' '); +} + +/** + * Filter columns based on current breakpoint + */ +function getVisibleColumns(columns: ColumnConfig[], breakpoint: Breakpoint): ColumnConfig[] { + const breakpointOrder: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; + const currentIndex = breakpointOrder.indexOf(breakpoint); + + return columns.filter(col => { + const colBreakpoint = PRIORITY_BREAKPOINTS[col.priority]; + const colIndex = breakpointOrder.indexOf(colBreakpoint); + return colIndex <= currentIndex; + }); +} + +/** + * Hook to generate responsive CSS grid templates based on column configuration. + * + * This hook automatically adjusts the grid template based on the current + * viewport width, showing/hiding columns according to their priority. + * + * @example + * ```tsx + * const columns = [ + * { id: 'name', label: 'Name', priority: 'essential', minWidth: '150px', flex: 1.5 }, + * { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '100px', flex: 1 }, + * { id: 'diskRead', label: 'D Read', priority: 'detailed', minWidth: '100px', flex: 1 }, + * ]; + * + * const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns }); + * + * return ( + *
+ * + * {col =>
{col.label}
} + *
+ *
+ * ); + * ``` + */ +export function useGridTemplate(options: GridTemplateOptions): GridTemplateResult { + const { breakpoint, isMobile } = useBreakpoint(); + + // Support both static arrays and reactive accessors + const getColumns = (): ColumnConfig[] => { + const cols = options.columns; + return typeof cols === 'function' ? cols() : cols; + }; + + const visibleColumns = createMemo(() => { + return getVisibleColumns(getColumns(), breakpoint()); + }); + + const gridTemplate = createMemo(() => { + const visible = visibleColumns(); + + // On mobile with sticky column, we handle layout differently + // The first column is sticky and outside the grid + if (isMobile() && options.hasStickyColumn && visible.length > 1) { + // Skip the first column (it's sticky) and generate template for the rest + return generateGridTemplate(visible.slice(1)); + } + + return generateGridTemplate(visible); + }); + + const isColumnVisible = (columnId: string): boolean => { + return visibleColumns().some(col => col.id === columnId); + }; + + return { + gridTemplate, + visibleColumns, + isColumnVisible, + breakpoint, + isMobile, + }; +} + +/** + * Simple utility to generate a static grid template (non-reactive) + */ +export function generateStaticGridTemplate( + columns: ColumnConfig[], + breakpoint: Breakpoint = 'xl' +): string { + const visible = getVisibleColumns(columns, breakpoint); + return generateGridTemplate(visible); +} + +/** + * Generate responsive grid template classes for Tailwind + * Returns an object with breakpoint-specific templates + */ +export function generateResponsiveTemplates(columns: ColumnConfig[]): Record { + const breakpoints: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; + + return Object.fromEntries( + breakpoints.map(bp => [bp, generateStaticGridTemplate(columns, bp)]) + ) as Record; +} + +/** + * Get Tailwind CSS classes for column visibility based on priority + */ +export function getColumnVisibilityClass( + priority: ColumnConfig['priority'], + displayType: 'flex' | 'block' | 'table-cell' | 'grid' = 'flex' +): string { + const bp = PRIORITY_BREAKPOINTS[priority]; + + if (bp === 'xs') { + // Always visible + return displayType; + } + + return `hidden ${bp}:${displayType}`; +} diff --git a/frontend-modern/src/hooks/useBreakpoint.ts b/frontend-modern/src/hooks/useBreakpoint.ts new file mode 100644 index 0000000..639b8b4 --- /dev/null +++ b/frontend-modern/src/hooks/useBreakpoint.ts @@ -0,0 +1,161 @@ +import { createSignal, onMount, onCleanup, createMemo, Accessor } from 'solid-js'; + +/** + * Tailwind CSS breakpoint values (in pixels) + * These match Tailwind's default breakpoints + */ +export const BREAKPOINTS = { + xs: 0, + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + '2xl': 1536, +} as const; + +export type Breakpoint = keyof typeof BREAKPOINTS; + +/** + * Column priority tiers mapped to breakpoints + * - essential: Always visible (xs and up) + * - primary: Visible on small screens and up (sm: 640px+) + * - secondary: Visible on medium screens and up (md: 768px+) + * - supplementary: Visible on large screens and up (lg: 1024px+) + * - detailed: Visible on extra large screens and up (xl: 1280px+) + */ +export type ColumnPriority = 'essential' | 'primary' | 'secondary' | 'supplementary' | 'detailed'; + +export const PRIORITY_BREAKPOINTS: Record = { + essential: 'xs', + primary: 'sm', + secondary: 'md', + supplementary: 'lg', + detailed: 'xl', +}; + +/** + * Get the current breakpoint name based on window width + */ +function getBreakpointName(width: number): Breakpoint { + if (width >= BREAKPOINTS['2xl']) return '2xl'; + if (width >= BREAKPOINTS.xl) return 'xl'; + if (width >= BREAKPOINTS.lg) return 'lg'; + if (width >= BREAKPOINTS.md) return 'md'; + if (width >= BREAKPOINTS.sm) return 'sm'; + return 'xs'; +} + +export interface UseBreakpointReturn { + /** Current window width in pixels */ + width: Accessor; + /** Current breakpoint name (xs, sm, md, lg, xl, 2xl) */ + breakpoint: Accessor; + /** Check if current width is at least the given breakpoint */ + isAtLeast: (bp: Breakpoint) => boolean; + /** Check if current width is below the given breakpoint */ + isBelow: (bp: Breakpoint) => boolean; + /** Check if a column with the given priority should be visible */ + isVisible: (priority: ColumnPriority) => boolean; + /** Convenience booleans for common checks */ + isMobile: Accessor; + isTablet: Accessor; + isDesktop: Accessor; +} + +/** + * Reactive hook for tracking viewport width and breakpoints. + * + * Use this for conditional rendering based on screen size, + * which is more performant than rendering and hiding with CSS. + * + * @example + * ```tsx + * const { isAtLeast, isMobile, isVisible } = useBreakpoint(); + * + * return ( + * }> + * + * + * ); + * ``` + */ +export function useBreakpoint(): UseBreakpointReturn { + // Initialize with current window width (or 0 for SSR) + const initialWidth = typeof window !== 'undefined' ? window.innerWidth : 0; + const [width, setWidth] = createSignal(initialWidth); + + onMount(() => { + // Debounce resize events to avoid excessive re-renders + let resizeTimeout: number | undefined; + + const handleResize = () => { + if (resizeTimeout) { + window.cancelAnimationFrame(resizeTimeout); + } + resizeTimeout = window.requestAnimationFrame(() => { + setWidth(window.innerWidth); + }); + }; + + // Set initial width in case it changed between SSR and mount + setWidth(window.innerWidth); + + window.addEventListener('resize', handleResize, { passive: true }); + + onCleanup(() => { + window.removeEventListener('resize', handleResize); + if (resizeTimeout) { + window.cancelAnimationFrame(resizeTimeout); + } + }); + }); + + const breakpoint = createMemo(() => getBreakpointName(width())); + + const isAtLeast = (bp: Breakpoint): boolean => { + return width() >= BREAKPOINTS[bp]; + }; + + const isBelow = (bp: Breakpoint): boolean => { + return width() < BREAKPOINTS[bp]; + }; + + const isVisible = (priority: ColumnPriority): boolean => { + const minBreakpoint = PRIORITY_BREAKPOINTS[priority]; + return width() >= BREAKPOINTS[minBreakpoint]; + }; + + const isMobile = createMemo(() => width() < BREAKPOINTS.md); + const isTablet = createMemo(() => width() >= BREAKPOINTS.md && width() < BREAKPOINTS.xl); + const isDesktop = createMemo(() => width() >= BREAKPOINTS.xl); + + return { + width, + breakpoint, + isAtLeast, + isBelow, + isVisible, + isMobile, + isTablet, + isDesktop, + }; +} + +/** + * Get the Tailwind CSS class for hiding an element below a given breakpoint + */ +export function getVisibilityClass(priority: ColumnPriority, display: 'flex' | 'block' | 'table-cell' | 'grid' | 'inline' = 'flex'): string { + const bp = PRIORITY_BREAKPOINTS[priority]; + if (bp === 'xs') { + // Always visible, no hiding class needed + return display; + } + return `hidden ${bp}:${display}`; +} + +/** + * Get the minimum width for a priority level + */ +export function getPriorityMinWidth(priority: ColumnPriority): number { + return BREAKPOINTS[PRIORITY_BREAKPOINTS[priority]]; +} diff --git a/frontend-modern/src/types/responsive.ts b/frontend-modern/src/types/responsive.ts new file mode 100644 index 0000000..049fc4e --- /dev/null +++ b/frontend-modern/src/types/responsive.ts @@ -0,0 +1,356 @@ +import type { Component, JSX } from 'solid-js'; +import type { ColumnPriority, Breakpoint } from '@/hooks/useBreakpoint'; + +/** + * Configuration for a responsive table column + */ +export interface ColumnConfig { + /** Unique identifier for the column, typically matches a property key */ + id: string; + + /** Display label for the column header */ + label: string; + + /** Priority determines at which breakpoint the column becomes visible */ + priority: ColumnPriority; + + /** Icon component to show in header on mobile (optional) */ + icon?: Component<{ class?: string }>; + + /** Whether the column is sortable */ + sortable?: boolean; + + /** Sort key - defaults to id if sortable is true */ + sortKey?: string; + + /** Minimum width for the column (CSS value, e.g., '100px', '10%') */ + minWidth?: string; + + /** Maximum width for the column (CSS value) */ + maxWidth?: string; + + /** Flex grow factor for grid layout (default: 1) */ + flex?: number; + + /** Text alignment */ + align?: 'left' | 'center' | 'right'; + + /** Whether this column should be sticky (typically for name columns) */ + sticky?: boolean; + + /** Custom CSS class for the column */ + className?: string; + + /** Custom CSS class for the header */ + headerClassName?: string; + + /** Custom CSS class for cells */ + cellClassName?: string; +} + +/** + * Extended column config with render functions + */ +export interface ColumnConfigWithRender extends ColumnConfig { + /** + * Render function for mobile view (compact) + * If not provided, falls back to renderDesktop + */ + renderMobile?: (item: T, index: number) => JSX.Element; + + /** + * Render function for desktop view (full) + * Required if renderMobile is provided + */ + renderDesktop?: (item: T, index: number) => JSX.Element; + + /** + * Simple render function for both mobile and desktop + * Use this for columns that don't need different mobile/desktop rendering + */ + render?: (item: T, index: number) => JSX.Element; + + /** + * Get sort value from item (for custom sorting) + */ + getSortValue?: (item: T) => string | number | null | undefined; +} + +/** + * Common metric column configuration for CPU, Memory, Disk metrics + */ +export interface MetricColumnConfig { + type: 'cpu' | 'memory' | 'disk'; + getValue: (item: unknown) => number; + getLabel?: (item: unknown) => string; + getSublabel?: (item: unknown) => string | undefined; + getResourceId?: (item: unknown) => string; +} + +/** + * Sort state for a table + */ +export interface SortState { + key: string | null; + direction: 'asc' | 'desc'; +} + +/** + * Grid template configuration for responsive tables + */ +export interface GridTemplateConfig { + /** Column configurations */ + columns: ColumnConfig[]; + + /** Current breakpoint for visibility calculations */ + breakpoint: Breakpoint; + + /** Whether to include sticky column handling */ + hasStickyColumn?: boolean; +} + +/** + * Props for responsive table header + */ +export interface ResponsiveHeaderProps { + /** Column configuration */ + column: ColumnConfig; + + /** Current sort state */ + sortState?: SortState; + + /** Sort handler */ + onSort?: (key: string) => void; + + /** Whether currently on mobile */ + isMobile?: boolean; +} + +/** + * Props for responsive metric cell + */ +export interface ResponsiveMetricCellProps { + /** Metric value (0-100 percentage) */ + value: number; + + /** Metric type for theming */ + type: 'cpu' | 'memory' | 'disk'; + + /** Primary label (usually formatted percentage) */ + label?: string; + + /** Secondary label (e.g., "4.2GB / 8GB") */ + sublabel?: string; + + /** Resource ID for sparkline tracking */ + resourceId?: string; + + /** Whether the resource is running/online */ + isRunning?: boolean; + + /** Whether currently on mobile */ + isMobile?: boolean; + + /** Fallback content when not running */ + fallback?: JSX.Element; +} + +/** + * Standard column definitions that can be reused across tables + */ +export const STANDARD_COLUMNS = { + /** Name/identifier column - always visible, typically sticky on mobile */ + name: { + id: 'name', + label: 'Name', + priority: 'essential' as ColumnPriority, + sortable: true, + sticky: true, + minWidth: '150px', + flex: 1.5, + }, + + /** Type badge column (VM/LXC, Container/Service, etc.) */ + type: { + id: 'type', + label: 'Type', + priority: 'essential' as ColumnPriority, + sortable: true, + minWidth: '60px', + maxWidth: '80px', + align: 'center' as const, + }, + + /** VMID column */ + vmid: { + id: 'vmid', + label: 'VMID', + priority: 'essential' as ColumnPriority, + sortable: true, + minWidth: '60px', + maxWidth: '80px', + align: 'center' as const, + }, + + /** Status column */ + status: { + id: 'status', + label: 'Status', + priority: 'primary' as ColumnPriority, + sortable: true, + minWidth: '70px', + maxWidth: '100px', + align: 'center' as const, + }, + + /** Uptime column */ + uptime: { + id: 'uptime', + label: 'Uptime', + priority: 'secondary' as ColumnPriority, + sortable: true, + minWidth: '80px', + maxWidth: '100px', + }, + + /** CPU metric column */ + cpu: { + id: 'cpu', + label: 'CPU', + priority: 'essential' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1.5, + align: 'center' as const, + }, + + /** Memory metric column */ + memory: { + id: 'memory', + label: 'Memory', + priority: 'essential' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1.5, + align: 'center' as const, + }, + + /** Disk metric column */ + disk: { + id: 'disk', + label: 'Disk', + priority: 'essential' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1.5, + align: 'center' as const, + }, + + /** Disk read I/O column */ + diskRead: { + id: 'diskRead', + label: 'D Read', + priority: 'detailed' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1, + }, + + /** Disk write I/O column */ + diskWrite: { + id: 'diskWrite', + label: 'D Write', + priority: 'detailed' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1, + }, + + /** Network in column */ + networkIn: { + id: 'networkIn', + label: 'Net In', + priority: 'detailed' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1, + }, + + /** Network out column */ + networkOut: { + id: 'networkOut', + label: 'Net Out', + priority: 'detailed' as ColumnPriority, + sortable: true, + minWidth: '100px', + flex: 1, + }, + + /** Temperature column */ + temperature: { + id: 'temperature', + label: 'Temp', + priority: 'supplementary' as ColumnPriority, + sortable: true, + minWidth: '45px', + maxWidth: '65px', + align: 'center' as const, + }, + + /** Node column (for grouped views) */ + node: { + id: 'node', + label: 'Node', + priority: 'supplementary' as ColumnPriority, + sortable: true, + minWidth: '100px', + }, + + /** Storage column */ + storage: { + id: 'storage', + label: 'Storage', + priority: 'supplementary' as ColumnPriority, + sortable: true, + minWidth: '100px', + }, + + /** Size column */ + size: { + id: 'size', + label: 'Size', + priority: 'secondary' as ColumnPriority, + sortable: true, + minWidth: '80px', + align: 'right' as const, + }, + + /** Time/date column */ + time: { + id: 'time', + label: 'Time', + priority: 'primary' as ColumnPriority, + sortable: true, + minWidth: '120px', + }, + + /** Actions column (expand buttons, etc.) */ + actions: { + id: 'actions', + label: '', + priority: 'essential' as ColumnPriority, + sortable: false, + minWidth: '32px', + maxWidth: '48px', + }, +} satisfies Record; + +/** + * Helper to create a column config from a standard column with overrides + */ +export function createColumn( + base: ColumnConfig, + overrides?: Partial> +): ColumnConfigWithRender { + return { ...base, ...overrides }; +} diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index cf0aab1..5dd2e9c 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -15,6 +15,15 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string { return `${formatBytes(bytesPerSecond, decimals)}/s`; } +export function formatSpeedCompact(bytesPerSecond: number): string { + if (!bytesPerSecond || bytesPerSecond < 0) return '0'; + const k = 1024; + const mbps = bytesPerSecond / (k * k); + if (mbps < 1) return '<1'; + if (mbps < 10) return mbps.toFixed(1); + return Math.round(mbps).toString(); +} + export function formatPercent(value: number): string { if (!Number.isFinite(value)) return '0%'; const abs = Math.abs(value);