Convert data tables from CSS Grid to HTML tables

- Replace CSS Grid with native <table> 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
This commit is contained in:
rcourtman 2025-11-29 21:20:02 +00:00
parent a5fea152e8
commit f32d22dc41
8 changed files with 1390 additions and 1555 deletions

View file

@ -2,7 +2,6 @@ import { createSignal, createMemo, createEffect, For, Show, onMount } from 'soli
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api'; import type { VM, Container, Node } from '@/types/api';
import { GuestRow, GUEST_COLUMNS } from './GuestRow'; import { GuestRow, GUEST_COLUMNS } from './GuestRow';
import { useGridTemplate } from '@/components/shared/responsive';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts'; import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation'; import { useAlertsActivation } from '@/stores/alertsActivation';
@ -259,9 +258,8 @@ export function Dashboard(props: DashboardProps) {
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid'); const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Use the same grid template as GuestRow for header alignment // Total columns for colspan calculations
const { gridTemplate: headerGridTemplate } = useGridTemplate({ columns: GUEST_COLUMNS }); const totalColumns = GUEST_COLUMNS.length;
// Load all guest metadata on mount (single API call for all guests) // Load all guest metadata on mount (single API call for all guests)
onMount(async () => { onMount(async () => {
@ -929,119 +927,101 @@ export function Dashboard(props: DashboardProps) {
{/* Table View */} {/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}> <Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<ComponentErrorBoundary name="Guest Table"> <ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
{/* Desktop Header */} <table class="w-full border-collapse whitespace-nowrap">
<div <thead>
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0" <tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
style={{ 'grid-template-columns': headerGridTemplate() }}
>
{/* Name Header */} {/* Name Header */}
<div <th
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap" class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('name')} onClick={() => handleSort('name')}
> >
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div> </th>
{/* Type */} {/* Type */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('type')} onClick={() => handleSort('type')}
title="Type"
> >
<span class="hidden xl:inline">Type</span> Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="xl:hidden">T</span> </th>
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* VMID */} {/* VMID */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('vmid')} onClick={() => handleSort('vmid')}
title="VMID"
> >
<span class="hidden xl:inline">VMID</span> VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="xl:hidden">ID</span> </th>
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Uptime */} {/* Uptime */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('uptime')} onClick={() => handleSort('uptime')}
title="Uptime"
> >
<span class="hidden xl:inline">Uptime</span> Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="xl:hidden">Up</span> </th>
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* CPU */} {/* CPU */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('cpu')} onClick={() => handleSort('cpu')}
> >
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div> </th>
{/* Memory */} {/* Memory */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('memory')} onClick={() => handleSort('memory')}
title="Memory"
> >
<span class="hidden xl:inline">Memory</span> Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="xl:hidden">Mem</span> </th>
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk */} {/* Disk */}
<div <th
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap" class="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"
onClick={() => handleSort('disk')} onClick={() => handleSort('disk')}
> >
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div> </th>
{/* Disk Read */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskRead')}
title="Disk Read"
>
<span class="hidden xl:inline">Disk Read</span>
<span class="xl:hidden">D Rd</span>
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk Write */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
title="Disk Write"
>
<span class="hidden xl:inline">Disk Write</span>
<span class="xl:hidden">D Wr</span>
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net In */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkIn')}
title="Net In"
>
<span class="hidden xl:inline">Net In</span>
<span class="xl:hidden">N In</span>
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net Out */}
<div
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkOut')}
title="Net Out"
>
<span class="hidden xl:inline">Net Out</span>
<span class="xl:hidden">N Out</span>
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</div>
{/* Guest List */} {/* Disk Read */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0"> <th
class="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"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Write */}
<th
class="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"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net In */}
<th
class="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"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net Out */}
<th
class="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"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For <For
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => { each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
const nodeA = nodeByInstance()[instanceIdA]; const nodeA = nodeByInstance()[instanceIdA];
@ -1057,7 +1037,7 @@ export function Dashboard(props: DashboardProps) {
return ( return (
<> <>
<Show when={node && groupingMode() === 'grouped'}> <Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} /> <NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
</Show> </Show>
<For each={guests} fallback={<></>}> <For each={guests} fallback={<></>}>
{(guest) => { {(guest) => {
@ -1087,7 +1067,8 @@ export function Dashboard(props: DashboardProps) {
); );
}} }}
</For> </For>
</div> </tbody>
</table>
</div> </div>
</Card> </Card>
</ComponentErrorBoundary> </ComponentErrorBoundary>

View file

@ -1,5 +1,5 @@
import { GuestDrawer } from './GuestDrawer'; 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 type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format'; import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
import { TagBadges } from './TagBadges'; import { TagBadges } from './TagBadges';
@ -13,7 +13,8 @@ import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
import { type ColumnPriority } from '@/hooks/useBreakpoint'; 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; type Guest = VM | Container;
@ -113,19 +114,19 @@ interface ColumnDef {
} }
export const GUEST_COLUMNS: ColumnDef[] = [ export const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', maxWidth: '300px' }, { id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' }, { id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' }, { id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' }, { id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
// Metric columns - fixed width to match progress bar max-width (140px + padding) // Metric columns - fixed min/max width to match progress bar
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', maxWidth: '156px' }, { id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
// I/O columns - fixed width // I/O columns - auto sizing
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, { id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, { id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' }, { id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '56px', maxWidth: '70px' }, { id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
]; ];
interface GuestRowProps { interface GuestRowProps {
@ -156,8 +157,8 @@ export function GuestRow(props: GuestRowProps) {
const guestId = createMemo(() => buildGuestId(props.guest)); const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId()); const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
// Use the responsive grid template hook for dynamic column visibility // Use breakpoint hook directly for responsive behavior
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS }); const { isMobile } = useBreakpoint();
// Create namespaced metrics key // Create namespaced metrics key
const metricsKey = createMemo(() => { const metricsKey = createMemo(() => {
@ -485,27 +486,33 @@ export function GuestRow(props: GuestRowProps) {
}; };
}); });
// Render cell content based on column type // Total columns for colspan calculation
const renderCell = (column: ColumnDef) => { const totalColumns = GUEST_COLUMNS.length;
switch (column.id) {
case 'name':
return ( return (
<div class={`px-1 py-1 flex items-center min-w-0 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}> <>
<div class="flex items-center gap-2 min-w-0 w-full"> <tr
<div class="flex items-center gap-1.5 flex-1 min-w-0"> class={rowClass()}
style={rowStyle()}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<StatusDot <StatusDot
variant={guestStatus().variant} variant={guestStatus().variant}
title={guestStatus().label} title={guestStatus().label}
ariaLabel={guestStatus().label} ariaLabel={guestStatus().label}
size="xs" size="xs"
/> />
<div class="flex-1 min-w-0">
<Show <Show
when={isEditingUrl()} when={isEditingUrl()}
fallback={ fallback={
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<span <span
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap" class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none whitespace-nowrap"
style="cursor: text;" style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`} title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl} onClick={startEditingUrl}
@ -541,7 +548,7 @@ export function GuestRow(props: GuestRowProps) {
</div> </div>
} }
> >
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor> <div class="flex items-center gap-1 min-w-0" data-url-editor>
<input <input
ref={urlInputRef} ref={urlInputRef}
type="text" type="text"
@ -562,7 +569,7 @@ export function GuestRow(props: GuestRowProps) {
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8006" 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" 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"
/> />
<button <button
type="button" type="button"
@ -591,7 +598,6 @@ export function GuestRow(props: GuestRowProps) {
</div> </div>
</Show> </Show>
</div> </div>
</div>
<Show when={!isEditingUrl()}> <Show when={!isEditingUrl()}>
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}> <div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
@ -606,21 +612,20 @@ export function GuestRow(props: GuestRowProps) {
<Show when={lockLabel()}> <Show when={lockLabel()}>
<span <span
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide" class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap"
title={`Guest is locked (${lockLabel()})`} title={`Guest is locked (${lockLabel()})`}
> >
Lock: {lockLabel()} Lock: {lockLabel()}
</span> </span>
</Show> </Show>
</div> </div>
</div> </td>
);
case 'type': {/* Type */}
return ( <td class="px-2 py-1 align-middle">
<div class="px-0.5 py-1 flex justify-center items-center"> <div class="flex justify-center">
<span <span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded ${props.guest.type === 'qemu' class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`} }`}
@ -629,31 +634,32 @@ export function GuestRow(props: GuestRowProps) {
{isVM(props.guest) ? 'VM' : 'LXC'} {isVM(props.guest) ? 'VM' : 'LXC'}
</span> </span>
</div> </div>
); </td>
case 'vmid': {/* VMID */}
return ( <td class="px-2 py-1 align-middle">
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400"> <div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
{props.guest.vmid} {props.guest.vmid}
</div> </div>
); </td>
case 'uptime': {/* Uptime */}
return ( <td class="px-2 py-1 align-middle">
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center"> <div class="flex justify-center">
<div class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}> <span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
<Show when={isRunning()} fallback="-"> <Show when={isRunning()} fallback="-">
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}> <Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
{formatUptime(props.guest.uptime, true)} {formatUptime(props.guest.uptime, true)}
</Show> </Show>
</Show> </Show>
</span>
</div> </div>
</div> </td>
);
case 'cpu': {/* CPU */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center"> <Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell <ResponsiveMetricCell
value={cpuPercent()} value={cpuPercent()}
type="cpu" type="cpu"
@ -664,18 +670,31 @@ export function GuestRow(props: GuestRowProps) {
: undefined : undefined
} }
isRunning={isRunning()} isRunning={isRunning()}
showMobile={isMobile()} showMobile={true}
class="w-full"
/> />
</div> </div>
); </Show>
<div class="hidden md:block">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
sublabel={
props.guest.cpus
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined
}
isRunning={isRunning()}
showMobile={false}
/>
</div>
</td>
case 'memory': {/* Memory */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center"> <div title={memoryTooltip() ?? undefined}>
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
<Show when={isMobile()}> <Show when={isMobile()}>
<div class="md:hidden"> <div class="md:hidden flex justify-center">
<ResponsiveMetricCell <ResponsiveMetricCell
value={memPercent()} value={memPercent()}
type="memory" type="memory"
@ -683,11 +702,10 @@ export function GuestRow(props: GuestRowProps) {
sublabel={memoryUsageLabel()} sublabel={memoryUsageLabel()}
isRunning={isRunning()} isRunning={isRunning()}
showMobile={true} showMobile={true}
class="w-full"
/> />
</div> </div>
</Show> </Show>
<div class="hidden md:block w-full"> <div class="hidden md:block">
<StackedMemoryBar <StackedMemoryBar
used={props.guest.memory?.used || 0} used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0} total={props.guest.memory?.total || 0}
@ -697,101 +715,81 @@ export function GuestRow(props: GuestRowProps) {
/> />
</div> </div>
</div> </div>
</div> </td>
);
case 'disk': {/* Disk */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
<Show <Show
when={hasDiskUsage()} when={hasDiskUsage()}
fallback={ fallback={
<div class="flex justify-center">
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}> <span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
- -
</span> </span>
</div>
} }
> >
{/* Mobile: simple percentage text */}
<Show when={isMobile()}> <Show when={isMobile()}>
<div class="md:hidden text-xs text-center text-gray-600 dark:text-gray-400"> <div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())} {formatPercent(diskPercent())}
</div> </div>
</Show> </Show>
{/* Desktop: stacked disk bar for multiple disks */} <div class={isMobile() ? 'hidden md:block' : ''}>
<div class={isMobile() ? 'hidden md:block w-full' : 'w-full'}>
<StackedDiskBar <StackedDiskBar
disks={props.guest.disks} disks={props.guest.disks}
aggregateDisk={props.guest.disk} aggregateDisk={props.guest.disk}
/> />
</div> </div>
</Show> </Show>
</div> </td>
);
case 'diskRead': {/* Disk Read */}
return ( <td class="px-2 py-1 align-middle">
<div class="py-1 flex justify-center items-center min-h-[24px]"> <div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}> <Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span> <span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
</Show> </Show>
</div> </div>
); </td>
case 'diskWrite': {/* Disk Write */}
return ( <td class="px-2 py-1 align-middle">
<div class="py-1 flex justify-center items-center min-h-[24px]"> <div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}> <Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span> <span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
</Show> </Show>
</div> </div>
); </td>
case 'netIn': {/* Net In */}
return ( <td class="px-2 py-1 align-middle">
<div class="py-1 flex justify-center items-center min-h-[24px]"> <div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}> <Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span> <span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
</Show> </Show>
</div> </div>
); </td>
case 'netOut': {/* Net Out */}
return ( <td class="px-2 py-1 align-middle">
<div class="py-1 flex justify-center items-center min-h-[24px]"> <div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}> <Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span> <span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
</Show> </Show>
</div> </div>
); </td>
</tr>
default:
return null;
}
};
return (
<>
<div
class={`${rowClass()} grid items-center`}
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
<For each={visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
<Show when={drawerOpen() && canShowDrawer()}> <Show when={drawerOpen() && canShowDrawer()}>
<div class="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700"> <tr class="bg-gray-50 dark:bg-gray-900/50">
<div class="p-4"> <td colspan={totalColumns} class="p-4 border-b border-gray-200 dark:border-gray-700">
<GuestDrawer <GuestDrawer
guest={props.guest} guest={props.guest}
metricsKey={metricsKey()} metricsKey={metricsKey()}
onClose={() => setCurrentlyExpandedGuestId(null)} onClose={() => setCurrentlyExpandedGuestId(null)}
/> />
</div> </td>
</div> </tr>
</Show> </Show>
</> </>
); );

View file

@ -136,12 +136,12 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return ( return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="300px" persistKey="docker-host-summary"> <ScrollableTable persistKey="docker-host-summary">
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap"> <table class="w-full border-collapse whitespace-nowrap">
<thead> <thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600"> <tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<th <th
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[35%] md:w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap" class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
onClick={() => handleSort('name')} onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabIndex={0} tabIndex={0}
@ -150,47 +150,47 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
> >
Host {renderSortIndicator('name')} Host {renderSortIndicator('name')}
</th> </th>
<th class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] md:w-[6%] whitespace-nowrap"> <th class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
Status Status
</th> </th>
<th <th
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap" class="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"
onClick={() => handleSort('cpu')} onClick={() => handleSort('cpu')}
> >
CPU {renderSortIndicator('cpu')} CPU {renderSortIndicator('cpu')}
</th> </th>
<th <th
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap" class="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"
onClick={() => handleSort('memory')} onClick={() => handleSort('memory')}
> >
Memory {renderSortIndicator('memory')} Memory {renderSortIndicator('memory')}
</th> </th>
<th <th
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap" class="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"
onClick={() => handleSort('disk')} onClick={() => handleSort('disk')}
> >
Disk {renderSortIndicator('disk')} Disk {renderSortIndicator('disk')}
</th> </th>
<th <th
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap" class="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"
onClick={() => handleSort('running')} onClick={() => handleSort('running')}
> >
Containers {renderSortIndicator('running')} Containers {renderSortIndicator('running')}
</th> </th>
<th <th
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell" class="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"
onClick={() => handleSort('uptime')} onClick={() => handleSort('uptime')}
> >
Uptime {renderSortIndicator('uptime')} Uptime {renderSortIndicator('uptime')}
</th> </th>
<th <th
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell" class="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"
onClick={() => handleSort('lastSeen')} onClick={() => handleSort('lastSeen')}
> >
Last Update {renderSortIndicator('lastSeen')} Last Update {renderSortIndicator('lastSeen')}
</th> </th>
<th <th
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell" class="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"
onClick={() => handleSort('agent')} onClick={() => handleSort('agent')}
> >
Agent {renderSortIndicator('agent')} Agent {renderSortIndicator('agent')}
@ -278,14 +278,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</div> </div>
</div> </div>
</td> </td>
<td class="px-0.5 md:px-2 py-1 align-middle"> <td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap"> <div class="flex justify-center items-center h-full w-full whitespace-nowrap">
{renderDockerStatusBadge(summary.host.status)} {renderDockerStatusBadge(summary.host.status)}
</div> </div>
</td> </td>
<td class="px-0.5 md:px-2 py-1 align-middle"> <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}> <Show when={isMobile()}>
<div class="md:hidden"> <div class="md:hidden flex justify-center">
<MetricText value={summary.cpuPercent} type="cpu" /> <MetricText value={summary.cpuPercent} type="cpu" />
</div> </div>
</Show> </Show>
@ -298,7 +298,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/> />
</div> </div>
</td> </td>
<td class="px-0.5 md:px-2 py-1 align-middle"> <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<ResponsiveMetricCell <ResponsiveMetricCell
value={summary.memoryPercent} value={summary.memoryPercent}
type="memory" type="memory"
@ -308,7 +308,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
showMobile={isMobile()} showMobile={isMobile()}
/> />
</td> </td>
<td class="px-0.5 md:px-2 py-1 align-middle"> <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<ResponsiveMetricCell <ResponsiveMetricCell
value={summary.diskPercent} value={summary.diskPercent}
type="disk" type="disk"
@ -318,8 +318,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
showMobile={isMobile()} showMobile={isMobile()}
/> />
</td> </td>
<td class="px-0.5 md:px-2 py-1 align-middle"> <td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full px-2"> <div class="flex justify-center items-center h-full whitespace-nowrap w-full">
<Show <Show
when={summary.totalCount > 0} when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
@ -333,14 +333,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</Show> </Show>
</div> </div>
</td> </td>
<td class="hidden px-2 py-1 align-middle whitespace-nowrap sm:table-cell"> <td class="px-2 py-1 align-middle whitespace-nowrap">
<div class="flex justify-center items-center h-full"> <div class="flex justify-center items-center h-full">
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{uptimeLabel} {uptimeLabel}
</span> </span>
</div> </div>
</td> </td>
<td class="hidden px-2 py-1 align-middle sm:table-cell"> <td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full"> <div class="flex justify-center items-center h-full">
<Show <Show
when={summary.lastSeenRelative} when={summary.lastSeenRelative}
@ -357,7 +357,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</Show> </Show>
</div> </div>
</td> </td>
<td class="hidden px-2 py-1 align-middle sm:table-cell"> <td class="px-2 py-1 align-middle">
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full"> <div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
<Show <Show
when={summary.host.agentVersion} when={summary.host.agentVersion}

View file

@ -21,7 +21,8 @@ import {
getDockerServiceStatusIndicator, getDockerServiceStatusIndicator,
} from '@/utils/status'; } from '@/utils/status';
import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive'; import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import type { ColumnConfig } from '@/types/responsive'; import type { ColumnConfig } from '@/types/responsive';
@ -123,16 +124,16 @@ interface DockerColumnDef extends ColumnConfig {
// - supplementary: Visible on large screens and up (lg: 1024px+) // - supplementary: Visible on large screens and up (lg: 1024px+)
// - detailed: Visible on extra large screens and up (xl: 1280px+) // - detailed: Visible on extra large screens and up (xl: 1280px+)
export const DOCKER_COLUMNS: DockerColumnDef[] = [ export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: '100px', flex: 1, sortKey: 'resource' }, { id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '50px', maxWidth: '80px', sortKey: 'type' }, { id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type' },
{ id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'essential', minWidth: '80px', maxWidth: '250px', sortKey: 'image' }, { id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'image' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: '80px', maxWidth: '130px', sortKey: 'status' }, { id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status' },
// Metric columns - fixed width to match progress bar max-width (140px + padding) // Metric columns - need fixed width to match progress bar max-width (140px + padding)
// Note: Disk column removed - Docker API rarely provides this data // Note: Disk column removed - Docker API rarely provides this data
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'cpu' }, { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'memory' }, { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory' },
{ id: 'tasks', label: 'Tasks', shortLabel: 'Tasks', priority: 'essential', minWidth: '60px', maxWidth: '80px', sortKey: 'tasks' }, { id: 'tasks', label: 'Tasks', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'tasks' },
{ id: 'updated', label: 'Uptime', shortLabel: 'Uptime', priority: 'essential', minWidth: '70px', maxWidth: '90px', sortKey: 'updated' }, { id: 'updated', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'updated' },
]; ];
// Global state for currently expanded drawer (only one drawer open at a time) // Global state for currently expanded drawer (only one drawer open at a time)
@ -732,14 +733,14 @@ const UNGROUPED_RESOURCE_INDENT = 'pl-4 sm:pl-5 lg:pl-6';
const DockerHostGroupHeader: Component<{ const DockerHostGroupHeader: Component<{
host: DockerHost; host: DockerHost;
gridTemplate: Accessor<string>; columnCount: number;
visibleColumns: Accessor<ColumnConfig[]>;
}> = (props) => { }> = (props) => {
const displayName = getHostDisplayName(props.host); const displayName = getHostDisplayName(props.host);
const hostStatus = () => getDockerHostStatusIndicator(props.host); const hostStatus = () => getDockerHostStatusIndicator(props.host);
const isOnline = () => hostStatus().variant === 'success'; const isOnline = () => hostStatus().variant === 'success';
return ( return (
<div class="bg-gray-50 dark:bg-gray-900/40 py-0.5 pr-2 pl-4"> <tr class="bg-gray-50 dark:bg-gray-900/40">
<td colspan={props.columnCount} class="py-0.5 pr-2 pl-4">
<div <div
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`} class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
title={hostStatus().label} title={hostStatus().label}
@ -757,14 +758,13 @@ const DockerHostGroupHeader: Component<{
</span> </span>
</Show> </Show>
</div> </div>
</div> </td>
</tr>
); );
}; };
const DockerContainerRow: Component<{ const DockerContainerRow: Component<{
row: Extract<DockerRow, { kind: 'container' }>; row: Extract<DockerRow, { kind: 'container' }>;
visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>; isMobile: Accessor<boolean>;
customUrl?: string; customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void; onCustomUrlUpdate?: (resourceId: string, url: string) => void;
@ -1172,14 +1172,14 @@ const DockerContainerRow: Component<{
); );
case 'image': case 'image':
return ( return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate overflow-hidden"> <div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span title={container.image}>{container.image || '—'}</span> {container.image || '—'}
</div> </div>
); );
case 'status': case 'status':
return ( return (
<div class="px-2 py-0.5 text-xs overflow-hidden"> <div class="px-2 py-0.5 text-xs whitespace-nowrap">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap block truncate ${statusBadgeClass()}`}>{statusLabel()}</span> <span class={`rounded px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass()}`}>{statusLabel()}</span>
</div> </div>
); );
case 'cpu': case 'cpu':
@ -1270,18 +1270,26 @@ const DockerContainerRow: Component<{
return ( return (
<> <>
<div <tr
class={`grid items-center transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`} class={`transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
> >
<For each={props.visibleColumns()}> <For each={DOCKER_COLUMNS}>
{(column) => renderCell(column)} {(column) => (
<td
class="py-0.5 align-middle whitespace-nowrap"
style={{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined }}
>
{renderCell(column)}
</td>
)}
</For> </For>
</div> </tr>
<Show when={expanded() && hasDrawerContent()}> <Show when={expanded() && hasDrawerContent()}>
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3"> <div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30"> <div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
@ -1617,6 +1625,8 @@ const DockerContainerRow: Component<{
</Show> </Show>
</div> </div>
</div> </div>
</td>
</tr>
</Show> </Show>
</> </>
); );
@ -1624,8 +1634,6 @@ const DockerContainerRow: Component<{
const DockerServiceRow: Component<{ const DockerServiceRow: Component<{
row: Extract<DockerRow, { kind: 'service' }>; row: Extract<DockerRow, { kind: 'service' }>;
visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>; isMobile: Accessor<boolean>;
customUrl?: string; customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void; onCustomUrlUpdate?: (resourceId: string, url: string) => void;
@ -1923,8 +1931,8 @@ const DockerServiceRow: Component<{
); );
case 'image': case 'image':
return ( return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate"> <div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span title={service.image}>{service.image || '—'}</span> {service.image || '—'}
</div> </div>
); );
case 'status': case 'status':
@ -1967,18 +1975,26 @@ const DockerServiceRow: Component<{
return ( return (
<> <>
<div <tr
class={`grid items-center transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`} class={`transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
> >
<For each={props.visibleColumns()}> <For each={DOCKER_COLUMNS}>
{(column) => renderCell(column)} {(column) => (
<td
class="py-0.5 align-middle whitespace-nowrap"
style={{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined }}
>
{renderCell(column)}
</td>
)}
</For> </For>
</div> </tr>
<Show when={expanded() && hasTasks()}> <Show when={expanded() && hasTasks()}>
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3"> <div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30"> <div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
@ -2100,6 +2116,8 @@ const DockerServiceRow: Component<{
</div> </div>
</div> </div>
</div> </div>
</td>
</tr>
</Show> </Show>
</> </>
); );
@ -2114,8 +2132,8 @@ const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => {
}; };
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => { const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the responsive grid template hook for dynamic column visibility // Use the breakpoint hook for responsive behavior
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS }); const { isMobile } = useBreakpoint();
// Caches for stable object references to prevent re-animations // Caches for stable object references to prevent re-animations
const rowCache = new Map<string, DockerRow>(); const rowCache = new Map<string, DockerRow>();
@ -2442,8 +2460,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return row.kind === 'container' ? ( return row.kind === 'container' ? (
<DockerContainerRow <DockerContainerRow
row={row} row={row}
visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile} isMobile={isMobile}
customUrl={metadata?.customUrl} customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate} onCustomUrlUpdate={props.onCustomUrlUpdate}
@ -2453,8 +2469,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
) : ( ) : (
<DockerServiceRow <DockerServiceRow
row={row} row={row}
visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile} isMobile={isMobile}
customUrl={metadata?.customUrl} customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate} onCustomUrlUpdate={props.onCustomUrlUpdate}
@ -2485,19 +2499,18 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
> >
<Card padding="none" tone="glass" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
{/* Header Row */} <table class="w-full border-collapse whitespace-nowrap">
<div <thead>
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0" <tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20">
style={{ 'grid-template-columns': gridTemplate() }} <For each={DOCKER_COLUMNS}>
>
<For each={visibleColumns()}>
{(column) => { {(column) => {
const col = column as DockerColumnDef; const col = column as DockerColumnDef;
const colSortKey = col.sortKey as SortKey | undefined; const colSortKey = col.sortKey as SortKey | undefined;
const isResource = col.id === 'resource'; const isResource = col.id === 'resource';
return ( return (
<div <th
class={`${isResource ? 'pl-4 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap`} class={`${isResource ? 'pl-4 sm:pl-5 lg:pl-6 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 text-left font-medium whitespace-nowrap`}
style={{ "min-width": col.id === 'cpu' || col.id === 'memory' ? '140px' : undefined }}
onClick={() => colSortKey && handleSort(colSortKey)} onClick={() => colSortKey && handleSort(colSortKey)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0} tabIndex={0}
@ -2527,20 +2540,18 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={!isResource}> <Show when={!isResource}>
<div class="flex items-center gap-1 overflow-hidden"> <div class="flex items-center gap-1">
<span class="hidden xl:inline truncate">{col.label}</span> <span>{col.label}</span>
<span class="xl:hidden truncate">{col.shortLabel || col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)} {colSortKey && renderSortIndicator(colSortKey)}
</div> </div>
</Show> </Show>
</div> </th>
); );
}} }}
</For> </For>
</div> </tr>
</thead>
{/* Rows */} <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<Show <Show
when={isGroupedView()} when={isGroupedView()}
fallback={ fallback={
@ -2552,13 +2563,14 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
<For each={orderedGroups()}> <For each={orderedGroups()}>
{(group) => ( {(group) => (
<> <>
<DockerHostGroupHeader host={group.host} gridTemplate={gridTemplate} visibleColumns={visibleColumns} /> <DockerHostGroupHeader host={group.host} columnCount={DOCKER_COLUMNS.length} />
<For each={group.rows}>{(row) => renderRow(row, true)}</For> <For each={group.rows}>{(row) => renderRow(row, true)}</For>
</> </>
)} )}
</For> </For>
</Show> </Show>
</div> </tbody>
</table>
</div> </div>
</Card> </Card>

View file

@ -11,27 +11,16 @@ import { HostsFilter } from './HostsFilter';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { getHostStatusIndicator } from '@/utils/status'; 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 { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import type { ColumnConfig } from '@/types/responsive'; import { useBreakpoint } from '@/hooks/useBreakpoint';
import { STANDARD_COLUMNS } from '@/types/responsive';
// Global drawer state to persist across re-renders // Global drawer state to persist across re-renders
const drawerState = new Map<string, boolean>(); const drawerState = new Map<string, boolean>();
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime'; type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | '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' },
];
interface HostsOverviewProps { interface HostsOverviewProps {
hosts: Host[]; hosts: Host[];
@ -45,18 +34,21 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const [sortKey, setSortKey] = createSignal<SortKey>('name'); const [sortKey, setSortKey] = createSignal<SortKey>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const { isMobile } = useBreakpoint();
const handleSort = (key: string) => { const handleSort = (key: SortKey) => {
if (sortKey() === key) { if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else { } else {
setSortKey(key as SortKey); setSortKey(key);
setSortDirection('asc'); setSortDirection('asc');
} }
}; };
// Use responsive grid template const renderSortIndicator = (key: SortKey) => {
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS }); if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼';
};
// Keyboard listener to auto-focus search // Keyboard listener to auto-focus search
let searchInputRef: HTMLInputElement | undefined; let searchInputRef: HTMLInputElement | undefined;
@ -116,6 +108,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
case 'memory': case 'memory':
comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0); comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0);
break; 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': case 'uptime':
comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0); comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0);
break; break;
@ -154,92 +152,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host));
}); });
const renderCell = (column: ColumnConfig, host: Host) => { const getTemperatureValue = (host: Host) => {
const cpuPercent = () => host.cpuUsage ?? 0;
const memPercent = () => host.memory?.usage ?? 0;
const hostStatus = createMemo(() => getHostStatusIndicator(host));
switch (column.id) {
case 'name':
return (
<div class="pl-4 pr-2 py-1 overflow-hidden">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
</div>
</div>
);
case 'platform':
return (
<div class="px-2 py-1 overflow-hidden">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</div>
);
case 'cpu':
return (
<div class="px-2 py-1 overflow-hidden">
<Show when={isMobile()}>
<div class="md:hidden">
<MetricText value={cpuPercent()} type="cpu" />
</div>
</Show>
<div class="hidden md:block w-full">
<EnhancedCPUBar
usage={cpuPercent()}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</div>
</div>
);
case 'memory':
return (
<div class="px-2 py-1 overflow-hidden">
<Show when={isMobile()}>
<div class="md:hidden">
<MetricText value={memPercent()} type="memory" />
</div>
</Show>
<div class="hidden md:block w-full">
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</div>
</div>
);
case 'temperature':
const tempValue = (() => {
if (!host.sensors?.temperatureCelsius) return null; if (!host.sensors?.temperatureCelsius) return null;
const temps = host.sensors.temperatureCelsius; const temps = host.sensors.temperatureCelsius;
@ -264,17 +177,9 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
if (values.length > 0) return values[0]; if (values.length > 0) return values[0];
return null; return null;
})(); };
return ( const getDiskStats = (host: Host) => {
<div class="px-2 py-1 overflow-hidden">
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}>
<TemperatureGauge value={tempValue!} />
</Show>
</div>
);
case 'disk':
const diskStats = (() => {
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 }; 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 totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0); const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
@ -283,48 +188,10 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
used: totalUsed, used: totalUsed,
total: totalSize total: totalSize
}; };
})();
const diskPercent = diskStats.percent;
return (
<div class="px-2 py-1 overflow-hidden">
<Show when={isMobile()}>
<div class="md:hidden">
<MetricText value={diskPercent} type="disk" />
</div>
</Show>
<div class="hidden md:block w-full">
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</div>
</div>
);
case 'uptime':
return (
<div class="px-2 py-1 text-right overflow-hidden">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</div>
);
default:
return null;
}
}; };
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 ( return (
<div class="space-y-4"> <div class="space-y-4">
<Show when={isLoading()}> <Show when={isLoading()}>
@ -397,32 +264,36 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
> >
<Card padding="none" tone="glass" class="overflow-hidden"> <Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
{/* Header */} <table class="w-full border-collapse whitespace-nowrap">
<div <thead>
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0" <tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
style={{ 'grid-template-columns': gridTemplate() }} <th
class={`${thClass} text-left pl-4`}
onClick={() => handleSort('name')}
> >
<For each={visibleColumns()}> Host {renderSortIndicator('name')}
{(column) => ( </th>
<div <th class={thClass} onClick={() => handleSort('platform')}>
class={`${column.id === 'name' ? 'pl-4 pr-2' : 'px-2'} py-1 flex items-center ${column.align === 'right' ? 'justify-end' : 'justify-start'} ${column.sortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`} Platform {renderSortIndicator('platform')}
onClick={() => column.sortable && column.sortKey && handleSort(column.sortKey)} </th>
> <th class={thClass} onClick={() => handleSort('cpu')}>
<div class="flex items-center gap-1"> CPU {renderSortIndicator('cpu')}
{column.label} </th>
{column.sortKey === sortKey() && ( <th class={thClass} onClick={() => handleSort('memory')}>
<span class="text-gray-400"> Memory {renderSortIndicator('memory')}
{sortDirection() === 'asc' ? '▲' : '▼'} </th>
</span> <th class={thClass}>
)} Temp
</div> </th>
</div> <th class={thClass} onClick={() => handleSort('disk')}>
)} Disk {renderSortIndicator('disk')}
</For> </th>
</div> <th class={`${thClass} text-right pr-4`} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
{/* Rows */} </th>
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0"> </tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredHosts()}> <For each={filteredHosts()}>
{(host) => { {(host) => {
// Drawer state // Drawer state
@ -467,28 +338,147 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
}); });
const rowClass = () => { const rowClass = () => {
const base = 'grid items-center transition-all duration-200'; const base = 'transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
return `${base} ${hover} ${clickable} ${expanded}`; 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);
return ( return (
<> <>
<div <tr
class={rowClass()} class={rowClass()}
style={{ 'grid-template-columns': gridTemplate() }}
onClick={toggleDrawer} onClick={toggleDrawer}
aria-expanded={drawerOpen()} aria-expanded={drawerOpen()}
> >
<For each={visibleColumns()}> {/* Host Name */}
{(column) => renderCell(column, host)} <td class="pl-4 pr-2 py-1 align-middle">
</For> <div class="flex items-center gap-2 min-w-0">
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.hostname}
</p>
</Show>
<Show when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div> </div>
</div>
</td>
{/* Platform */}
<td class="px-2 py-1 align-middle">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</td>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={cpuPercent} type="cpu" />
</div>
</Show>
<div class="hidden md:block">
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</div>
</td>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={memPercent} type="memory" />
</div>
</Show>
<div class="hidden md:block">
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</div>
</td>
{/* Temperature */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-400"></span>}>
<TemperatureGauge value={tempValue!} />
</Show>
</div>
</td>
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={diskStats.percent} type="disk" />
</div>
</Show>
<div class="hidden md:block">
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</div>
</td>
{/* Uptime */}
<td class="px-2 py-1 pr-4 align-middle">
<div class="flex justify-end">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</div>
</td>
</tr>
{/* Drawer - Additional Info */} {/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}> <Show when={drawerOpen() && hasDrawerContent()}>
<tr>
<td colspan={7} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50"> <div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
<div class="flex flex-wrap justify-start gap-3"> <div class="flex flex-wrap justify-start gap-3">
{/* System Info */} {/* System Info */}
@ -662,12 +652,15 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
</td>
</tr>
</Show> </Show>
</> </>
); );
}} }}
</For> </For>
</div> </tbody>
</table>
</div> </div>
</Card> </Card>
</Show> </Show>

View file

@ -10,92 +10,11 @@ import { useAlertsActivation } from '@/stores/alertsActivation';
import { buildMetricKey } from '@/utils/metricsKeys'; import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status'; import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
import { type ColumnPriority } from '@/hooks/useBreakpoint'; import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { useBreakpoint } from '@/hooks/useBreakpoint';
// Icons for mobile headers
const ClockIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const CpuIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
);
const MemoryIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
const DiskIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
);
const TempIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2a2 2 0 00-2 2v9.5a4 4 0 104 0V4a2 2 0 00-2-2z" />
<circle cx="12" cy="17" r="2" fill="currentColor" />
</svg>
);
const VMIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
const ContainerIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
);
const BackupIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
);
// 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'
};
interface NodeSummaryTableProps { interface NodeSummaryTableProps {
nodes: Node[]; nodes: Node[];
@ -114,6 +33,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const { activeAlerts, state } = useWebSocket(); const { activeAlerts, state } = useWebSocket();
const alertsActivation = useAlertsActivation(); const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const { isMobile } = useBreakpoint();
const isTemperatureMonitoringEnabled = (node: Node): boolean => { const isTemperatureMonitoringEnabled = (node: Node): boolean => {
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
@ -140,36 +60,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
| 'temperature' | 'temperature'
| CountSortKey; | CountSortKey;
interface CountColumn {
header: string;
key: CountSortKey;
icon: Component<{ class?: string }>;
minWidth?: string;
maxWidth?: string;
}
const [sortKey, setSortKey] = createSignal<SortKey>('default'); const [sortKey, setSortKey] = createSignal<SortKey>('default');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const countColumns = createMemo<CountColumn[]>(() => {
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(() => { const hasAnyTemperatureData = createMemo(() => {
return ( return (
props.nodes?.some( props.nodes?.some(
@ -178,34 +71,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
); );
}); });
// Build dynamic columns list
const columns = createMemo<ColumnDef[]>(() => {
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 nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
const vmCountsByNode = createMemo<Record<string, number>>(() => { const vmCountsByNode = createMemo<Record<string, number>>(() => {
@ -465,61 +330,66 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}); });
}); });
// Header cell component const renderSortIndicator = (key: SortKey) => {
const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => { if (sortKey() !== key) return null;
const Icon = cellProps.column.icon; return sortDirection() === 'asc' ? '▲' : '▼';
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 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";
const nameClass = cellProps.column.id === 'name' ? 'pl-3' : '';
return (
<div
class={`${baseClass} ${nameClass}`}
onClick={() => handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
tabindex="0"
role="button"
aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
<Show when={Icon !== undefined && isMobile()}>
<span class="md:hidden" title={cellProps.column.label}>
{Icon && <Icon class="w-4 h-4" />}
</span>
</Show>
<span class={Icon !== undefined && isMobile() ? 'hidden md:inline' : ''}>
{cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label}
</span>
<Show when={sortIndicator()}>
<span class="ml-0.5">{sortIndicator()}</span>
</Show>
</div>
);
};
return ( return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden"> <Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div> <div class="overflow-x-auto">
{/* Header */} <table class="w-full border-collapse whitespace-nowrap">
<div <thead>
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20" <tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
style={{ 'grid-template-columns': gridTemplate() }} <th
class={`${thClass} text-left pl-3`}
onClick={() => handleSort('name')}
> >
<For each={visibleColumns()}> {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
{(column) => ( </th>
<HeaderCell column={column} /> <th class={thClass} onClick={() => handleSort('uptime')}>
)} Uptime {renderSortIndicator('uptime')}
</For> </th>
</div> <th class={thClass} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
<div class="divide-y divide-gray-200 dark:divide-gray-700"> </th>
<th class={thClass} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<Show when={hasAnyTemperatureData()}>
<th class={thClass} onClick={() => handleSort('temperature')}>
Temp {renderSortIndicator('temperature')}
</th>
</Show>
<Show when={props.currentTab === 'dashboard'}>
<th class={thClass} onClick={() => handleSort('vmCount')}>
VMs {renderSortIndicator('vmCount')}
</th>
<th class={thClass} onClick={() => handleSort('containerCount')}>
CTs {renderSortIndicator('containerCount')}
</th>
</Show>
<Show when={props.currentTab === 'storage'}>
<th class={thClass} onClick={() => handleSort('storageCount')}>
Storage {renderSortIndicator('storageCount')}
</th>
<th class={thClass} onClick={() => handleSort('diskCount')}>
Disks {renderSortIndicator('diskCount')}
</th>
</Show>
<Show when={props.currentTab === 'backups'}>
<th class={thClass} onClick={() => handleSort('backupCount')}>
Backups {renderSortIndicator('backupCount')}
</th>
</Show>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedItems()}> <For each={sortedItems()}>
{(item) => { {(item) => {
const isPVEItem = isPVE(item); const isPVEItem = isPVE(item);
@ -579,11 +449,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
if (isSelected()) { if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`; return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`;
} }
if (showAlertHighlight()) { if (showAlertHighlight()) {
return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; 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}`;
} }
let className = baseHover; let className = baseHover;
@ -599,25 +472,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
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 ( return (
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}> <tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<StatusDot <StatusDot
variant={statusIndicator().variant} variant={statusIndicator().variant}
@ -633,30 +495,30 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
} }
target="_blank" target="_blank"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate" 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()} title={displayName()}
> >
{displayName()} {displayName()}
</a> </a>
<Show when={showActualName()}> <Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate"> <span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
({(node as Node).name}) ({(node as Node).name})
</span> </span>
</Show> </Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1"> <div class="hidden xl:flex items-center gap-1.5 ml-1">
<Show when={isPVEItem}> <Show when={isPVEItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"> <span class="text-[9px] px-1 py-0 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE PVE
</span> </span>
</Show> </Show>
<Show when={isPVEItem && node!.pveVersion}> <Show when={isPVEItem && node!.pveVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400"> <span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
v{node!.pveVersion.split('/')[1] || node!.pveVersion} v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span> </span>
</Show> </Show>
<Show when={isPVEItem && node!.isClusterMember !== undefined}> <Show when={isPVEItem && node!.isClusterMember !== undefined}>
<span <span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`} }`}
@ -665,25 +527,24 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</span> </span>
</Show> </Show>
<Show when={isPBSItem}> <Show when={isPBSItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"> <span class="text-[9px] px-1 py-0 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
PBS PBS
</span> </span>
</Show> </Show>
<Show when={isPBSItem && pbs!.version}> <Show when={isPBSItem && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400"> <span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
v{pbs!.version} v{pbs!.version}
</span> </span>
</Show> </Show>
</div> </div>
</div> </div>
</div> </td>
);
case 'uptime': {/* Uptime */}
return ( <td class="px-2 py-1 align-middle">
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}> <div class="flex justify-center">
<span <span
class={`text-xs ${isPVEItem && (node?.uptime ?? 0) < 3600 class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
? 'text-orange-500' ? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400' : 'text-gray-600 dark:text-gray-400'
}`} }`}
@ -695,17 +556,16 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</Show> </Show>
</span> </span>
</div> </div>
); </td>
case 'cpu': {/* CPU */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}> <Show when={isMobile()}>
<div class="md:hidden w-full"> <div class="md:hidden flex justify-center">
<MetricText value={cpuPercentValue} type="cpu" /> <MetricText value={cpuPercentValue} type="cpu" />
</div> </div>
</Show> </Show>
<div class="hidden md:block w-full"> <div class="hidden md:block">
<Show when={isPVEItem} fallback={ <Show when={isPVEItem} fallback={
<ResponsiveMetricCell <ResponsiveMetricCell
value={cpuPercentValue} value={cpuPercentValue}
@ -713,7 +573,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
resourceId={metricsKey} resourceId={metricsKey}
isRunning={online} isRunning={online}
showMobile={false} showMobile={false}
class="w-full"
/> />
}> }>
<EnhancedCPUBar <EnhancedCPUBar
@ -725,18 +584,16 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
/> />
</Show> </Show>
</div> </div>
</div> </td>
);
case 'memory': {/* Memory */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}> <Show when={isMobile()}>
<div class="md:hidden w-full"> <div class="md:hidden flex justify-center">
<MetricText value={memoryPercentValue} type="memory" /> <MetricText value={memoryPercentValue} type="memory" />
</div> </div>
</Show> </Show>
<div class="hidden md:block w-full"> <div class="hidden md:block">
<Show when={isPVEItem} fallback={ <Show when={isPVEItem} fallback={
<ResponsiveMetricCell <ResponsiveMetricCell
value={memoryPercentValue} value={memoryPercentValue}
@ -745,7 +602,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined} sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
isRunning={online} isRunning={online}
showMobile={false} showMobile={false}
class="w-full"
/> />
}> }>
<StackedMemoryBar <StackedMemoryBar
@ -757,12 +613,10 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
/> />
</Show> </Show>
</div> </div>
</div> </td>
);
case 'disk': {/* Disk */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class={`${baseCellClass} ${alignClass}`}>
<ResponsiveMetricCell <ResponsiveMetricCell
value={diskPercentValue} value={diskPercentValue}
type="disk" type="disk"
@ -770,14 +624,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
sublabel={diskSublabel} sublabel={diskSublabel}
isRunning={online} isRunning={online}
showMobile={isMobile()} showMobile={isMobile()}
class="w-full"
/> />
</div> </td>
);
case 'temperature': {/* Temperature */}
return ( <Show when={hasAnyTemperatureData()}>
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}> <td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show <Show
when={ when={
online && online &&
@ -823,41 +676,62 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
})()} })()}
</Show> </Show>
</div> </div>
); </td>
</Show>
// Count columns (vmCount, containerCount, storageCount, diskCount, backupCount) {/* Dashboard tab: VMs and CTs */}
default: <Show when={props.currentTab === 'dashboard'}>
if (column.id.endsWith('Count')) { <td class="px-2 py-1 align-middle">
const value = getCountValue(item, column.id as CountSortKey); <div class="flex justify-center">
const display = online ? value ?? '-' : '-'; <span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
const textClass = online {online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
? 'text-xs text-gray-700 dark:text-gray-300' </span>
: 'text-xs text-gray-400 dark:text-gray-500';
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<span class={textClass}>{display}</span>
</div> </div>
); </td>
} <td class="px-2 py-1 align-middle">
return null; <div class="flex justify-center">
} <span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
}; {online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
</span>
</div>
</td>
</Show>
return ( {/* Storage tab: Storage and Disks */}
<div <Show when={props.currentTab === 'storage'}>
class={`${rowClass()} grid items-center`} <td class="px-2 py-1 align-middle">
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }} <div class="flex justify-center">
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} <span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
> {online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
<For each={visibleColumns()}> </span>
{(column) => renderCell(column)}
</For>
</div> </div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
</span>
</div>
</td>
</Show>
{/* Backups tab: Backups */}
<Show when={props.currentTab === 'backups'}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}
</span>
</div>
</td>
</Show>
</tr>
); );
}} }}
</For > </For>
</div > </tbody>
</div > </table>
</Card > </div>
</Card>
); );
}; };

View file

@ -1,4 +1,4 @@
import { Component, createMemo, Show } from 'solid-js'; import { Component, createMemo } from 'solid-js';
interface TemperatureGaugeProps { interface TemperatureGaugeProps {
value: number; value: number;
@ -6,7 +6,6 @@ interface TemperatureGaugeProps {
max?: number | null; max?: number | null;
critical?: number; critical?: number;
warning?: number; warning?: number;
label?: string;
class?: string; class?: string;
} }
@ -14,54 +13,15 @@ export const TemperatureGauge: Component<TemperatureGaugeProps> = (props) => {
const critical = props.critical ?? 80; const critical = props.critical ?? 80;
const warning = props.warning ?? 70; 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(() => { const textColorClass = createMemo(() => {
if (props.value >= critical) return 'text-red-700 dark:text-red-400'; if (props.value >= critical) return 'text-red-600 dark:text-red-400';
if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400'; if (props.value >= warning) return 'text-yellow-600 dark:text-yellow-400';
return 'text-green-700 dark:text-green-400'; return 'text-gray-600 dark:text-gray-400';
}); });
return ( return (
<div class={`flex items-center gap-2 ${props.class || ''}`}> <span class={`text-xs whitespace-nowrap ${textColorClass()} ${props.class || ''}`}>
{/* Text Value */}
<span class={`text-xs font-medium w-[36px] text-right ${textColorClass()}`}>
{Math.round(props.value)}°C {Math.round(props.value)}°C
</span> </span>
{/* Bar */}
<div class="relative flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden min-w-[40px] max-w-[80px]">
<div
class={`h-full rounded-full transition-all duration-500 ${colorClass()}`}
style={{ width: `${percent()}%` }}
/>
{/* Min Marker */}
<Show when={props.min !== null && props.min !== undefined}>
<div
class="absolute top-0 bottom-0 w-0.5 bg-blue-400 opacity-70"
style={{ left: `${Math.min(100, Math.max(0, props.min!))}%` }}
title={`Min: ${Math.round(props.min!)}°C`}
/>
</Show>
{/* Max Marker */}
<Show when={props.max !== null && props.max !== undefined}>
<div
class="absolute top-0 bottom-0 w-0.5 bg-red-400 opacity-70"
style={{ left: `${Math.min(100, Math.max(0, props.max!))}%` }}
title={`Max: ${Math.round(props.max!)}°C`}
/>
</Show>
</div>
</div>
); );
}; };

View file

@ -11,6 +11,9 @@ export interface GridTemplateOptions {
/** Custom gap between columns (CSS value) */ /** Custom gap between columns (CSS value) */
gap?: string; gap?: string;
/** If true, show all columns regardless of breakpoint (use horizontal scroll instead) */
disableColumnHiding?: boolean;
} }
export interface GridTemplateResult { export interface GridTemplateResult {
@ -38,6 +41,16 @@ function generateGridTemplate(columns: ColumnConfig[]): string {
const min = col.minWidth || '50px'; const min = col.minWidth || '50px';
const max = col.maxWidth || `${col.flex || 1}fr`; 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 we have both min and max as fixed values, use the appropriate one
if (col.maxWidth && !col.maxWidth.includes('fr')) { if (col.maxWidth && !col.maxWidth.includes('fr')) {
return `minmax(${min}, ${col.maxWidth})`; return `minmax(${min}, ${col.maxWidth})`;
@ -100,7 +113,11 @@ export function useGridTemplate(options: GridTemplateOptions): GridTemplateResul
}; };
const visibleColumns = createMemo(() => { const visibleColumns = createMemo(() => {
return getVisibleColumns(getColumns(), breakpoint()); const cols = getColumns();
if (options.disableColumnHiding) {
return cols;
}
return getVisibleColumns(cols, breakpoint());
}); });
const gridTemplate = createMemo(() => { const gridTemplate = createMemo(() => {