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,165 +927,148 @@ 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 */}
> <th
{/* Name Header */} 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"
<div onClick={() => handleSort('name')}
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap" >
onClick={() => handleSort('name')} Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
> </th>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* 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" >
> Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Type</span> </th>
<span class="xl:hidden">T</span>
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* VMID */}
<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('vmid')}
title="VMID"
>
<span class="hidden xl:inline">VMID</span>
<span class="xl:hidden">ID</span>
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Uptime */}
<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('uptime')}
title="Uptime"
>
<span class="hidden xl:inline">Uptime</span>
<span class="xl:hidden">Up</span>
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* CPU */}
<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('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Memory */}
<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('memory')}
title="Memory"
>
<span class="hidden xl:inline">Memory</span>
<span class="xl:hidden">Mem</span>
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk */}
<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('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* 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 */} {/* VMID */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0"> <th
<For 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"
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => { onClick={() => handleSort('vmid')}
const nodeA = nodeByInstance()[instanceIdA]; >
const nodeB = nodeByInstance()[instanceIdB]; VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; </th>
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB); {/* Uptime */}
})} <th
fallback={<></>} 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')}
{([instanceId, guests]) => { >
const node = nodeByInstance()[instanceId]; Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
return ( </th>
<>
<Show when={node && groupingMode() === 'grouped'}> {/* CPU */}
<NodeGroupHeader node={node!} /> <th
</Show> 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"
<For each={guests} fallback={<></>}> onClick={() => handleSort('cpu')}
{(guest) => { >
const guestId = guest.id || `${guest.instance}-${guest.vmid}`; CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
const metadata = </th>
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`]; {/* Memory */}
const parentNode = node ?? resolveParentNode(guest); <th
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; 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"
return ( onClick={() => handleSort('memory')}
<ComponentErrorBoundary name="GuestRow"> >
<GuestRow Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
guest={guest} </th>
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl} {/* Disk */}
onTagClick={handleTagClick} <th
activeSearch={search()} 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"
parentNodeOnline={parentNodeOnline} onClick={() => handleSort('disk')}
onCustomUrlUpdate={handleCustomUrlUpdate} >
isGroupedView={groupingMode() === 'grouped'} Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
/> </th>
</ComponentErrorBoundary>
); {/* Disk Read */}
}} <th
</For> 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' ? '▲' : '▼')}
</For> </th>
</div>
{/* 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
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
})}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
/>
</ComponentErrorBoundary>
);
}}
</For>
</>
);
}}
</For>
</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,142 +486,146 @@ 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}`}> <tr
<div class="flex items-center gap-2 min-w-0 w-full"> class={rowClass()}
<div class="flex items-center gap-1.5 flex-1 min-w-0"> style={rowStyle()}
<StatusDot onClick={toggleDrawer}
variant={guestStatus().variant} aria-expanded={drawerOpen()}
title={guestStatus().label} >
ariaLabel={guestStatus().label} {/* Name */}
size="xs" <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-1 min-w-0"> <div class="flex items-center gap-1.5 min-w-0">
<Show <StatusDot
when={isEditingUrl()} variant={guestStatus().variant}
fallback={ title={guestStatus().label}
<div class="flex items-center gap-1.5 min-w-0"> ariaLabel={guestStatus().label}
<span size="xs"
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap" />
style="cursor: text;" <Show
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`} when={isEditingUrl()}
onClick={startEditingUrl} fallback={
data-guest-name-editable <div class="flex items-center gap-1.5 min-w-0">
<span
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none whitespace-nowrap"
style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-guest-name-editable
>
{props.guest.name}
</span>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
{props.guest.name} <path
</span> stroke-linecap="round"
<Show when={customUrl()}> stroke-linejoin="round"
<a stroke-width="2"
href={customUrl()} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
target="_blank" />
rel="noopener noreferrer" </svg>
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`} </a>
title="Open in new tab" </Show>
onClick={(event) => event.stopPropagation()} <BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
> </div>
<svg }
class="w-3.5 h-3.5" >
fill="none" <div class="flex items-center gap-1 min-w-0" data-url-editor>
stroke="currentColor" <input
viewBox="0 0 24 24" ref={urlInputRef}
> type="text"
<path value={editingUrlValue()}
stroke-linecap="round" data-guest-id={guestId()}
stroke-linejoin="round" onInput={(e) => {
stroke-width="2" editingValues.set(guestId(), e.currentTarget.value);
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" setEditingValuesVersion(v => v + 1);
/> }}
</svg> onKeyDown={(e) => {
</a> if (e.key === 'Enter') {
</Show> e.preventDefault();
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} /> saveUrl();
</div> } else if (e.key === 'Escape') {
} e.preventDefault();
> cancelEditingUrl();
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor> }
<input }}
ref={urlInputRef} onClick={(e) => e.stopPropagation()}
type="text" placeholder="https://192.168.1.100:8006"
value={editingUrlValue()} 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"
data-guest-id={guestId()}
onInput={(e) => {
editingValues.set(guestId(), e.currentTarget.value);
setEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://192.168.1.100:8006"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div>
</div>
<Show when={!isEditingUrl()}>
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/> />
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div> </div>
</Show> </Show>
<Show when={lockLabel()}>
<span
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
title={`Guest is locked (${lockLabel()})`}
>
Lock: {lockLabel()}
</span>
</Show>
</div> </div>
</div>
);
case 'type': <Show when={!isEditingUrl()}>
return ( <div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
<div class="px-0.5 py-1 flex justify-center items-center"> <TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
</div>
</Show>
<Show when={lockLabel()}>
<span
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap"
title={`Guest is locked (${lockLabel()})`}
>
Lock: {lockLabel()}
</span>
</Show>
</div>
</td>
{/* Type */}
<td class="px-2 py-1 align-middle">
<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,47 @@ 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>
</div> </span>
</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
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
sublabel={
props.guest.cpus
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined
}
isRunning={isRunning()}
showMobile={true}
/>
</div>
</Show>
<div class="hidden md:block">
<ResponsiveMetricCell <ResponsiveMetricCell
value={cpuPercent()} value={cpuPercent()}
type="cpu" type="cpu"
@ -664,134 +685,111 @@ export function GuestRow(props: GuestRowProps) {
: undefined : undefined
} }
isRunning={isRunning()} isRunning={isRunning()}
showMobile={isMobile()} showMobile={false}
class="w-full"
/> />
</div> </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 flex justify-center">
<div class="md:hidden"> <ResponsiveMetricCell
<ResponsiveMetricCell value={memPercent()}
value={memPercent()} type="memory"
type="memory" resourceId={metricsKey()}
resourceId={metricsKey()} sublabel={memoryUsageLabel()}
sublabel={memoryUsageLabel()} isRunning={isRunning()}
isRunning={isRunning()} showMobile={true}
showMobile={true}
class="w-full"
/>
</div>
</Show>
<div class="hidden md:block w-full">
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
/>
</div>
</div>
</div>
);
case 'disk':
return (
<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={hasDiskUsage()}
fallback={
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
-
</span>
}
>
{/* Mobile: simple percentage text */}
<Show when={isMobile()}>
<div class="md:hidden text-xs text-center text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())}
</div>
</Show>
{/* Desktop: stacked disk bar for multiple disks */}
<div class={isMobile() ? 'hidden md:block w-full' : 'w-full'}>
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
/> />
</div> </div>
</Show> </Show>
<div class="hidden md:block">
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
/>
</div>
</div> </div>
); </td>
case 'diskRead': {/* Disk */}
return ( <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div class="py-1 flex justify-center items-center min-h-[24px]"> <Show
when={hasDiskUsage()}
fallback={
<div class="flex justify-center">
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
-
</span>
</div>
}
>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())}
</div>
</Show>
<div class={isMobile() ? 'hidden md:block' : ''}>
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
/>
</div>
</Show>
</td>
{/* Disk Read */}
<td class="px-2 py-1 align-middle">
<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,39 +733,38 @@ 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">
<div <td colspan={props.columnCount} class="py-0.5 pr-2 pl-4">
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`} <div
title={hostStatus().label} class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
>
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label} title={hostStatus().label}
ariaLabel={hostStatus().label} >
size="xs" <StatusDot
/> variant={hostStatus().variant}
<span>{displayName}</span> title={hostStatus().label}
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}> ariaLabel={hostStatus().label}
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400"> size="xs"
({props.host.hostname}) />
</span> <span>{displayName}</span>
</Show> <Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
</div> <span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
</div> ({props.host.hostname})
</span>
</Show>
</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,24 +1270,32 @@ 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()}>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3"> <tr>
<div class="flex flex-wrap justify-start gap-3"> <td colspan={DOCKER_COLUMNS.length} class="p-0">
<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="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200"> <div class="flex flex-wrap justify-start gap-3">
Summary <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> <div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Summary
</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300"> <div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span> <span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
@ -1614,9 +1622,11 @@ const DockerContainerRow: Component<{
})} })}
</div> </div>
</div> </div>
</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,29 +1975,37 @@ 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()}>
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3"> <tr>
<div class="flex flex-wrap justify-start gap-3"> <td colspan={DOCKER_COLUMNS.length} class="p-0">
<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="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200"> <div class="flex flex-wrap justify-start gap-3">
<span>Tasks</span> <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">
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400"> <div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'} <span>Tasks</span>
</span> <span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
</div> {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
<div class="mt-2 overflow-x-auto"> </span>
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-800/60 text-xs"> </div>
<div class="mt-2 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-800/60 text-xs">
<thead class="bg-gray-100 dark:bg-gray-900/40 text-[10px] uppercase tracking-wide text-gray-600 dark:text-gray-200"> <thead class="bg-gray-100 dark:bg-gray-900/40 text-[10px] uppercase tracking-wide text-gray-600 dark:text-gray-200">
<tr> <tr>
<th class="py-1 pr-2 text-left font-medium">Task</th> <th class="py-1 pr-2 text-left font-medium">Task</th>
@ -2095,11 +2111,13 @@ const DockerServiceRow: Component<{
}} }}
</For> </For>
</tbody> </tbody>
</table> </table>
</div>
</div>
</div> </div>
</div> </div>
</div> </td>
</div> </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,80 +2499,78 @@ 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}>
> {(column) => {
<For each={visibleColumns()}> const col = column as DockerColumnDef;
{(column) => { const colSortKey = col.sortKey as SortKey | undefined;
const col = column as DockerColumnDef; const isResource = col.id === 'resource';
const colSortKey = col.sortKey as SortKey | undefined; return (
const isResource = col.id === 'resource'; <th
return ( 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`}
<div style={{ "min-width": col.id === 'cpu' || col.id === 'memory' ? '140px' : undefined }}
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`} 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} role="button"
role="button" aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'} >
> <Show when={isResource}>
<Show when={isResource}> <div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2"> <span>{col.label}</span>
<span>{col.label}</span> {colSortKey && renderSortIndicator(colSortKey)}
{colSortKey && renderSortIndicator(colSortKey)} <Show when={sortKey() === 'host'}>
<Show when={sortKey() === 'host'}> <span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span>
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span> </Show>
<Show when={sortKey() !== 'host'}>
<button
type="button"
class="ml-auto rounded bg-gray-200 px-2 py-0.5 text-[10px] font-medium text-gray-700 transition hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
onClick={(e) => {
e.stopPropagation();
resetHostGrouping();
}}
>
Group by host
</button>
</Show>
</div>
</Show> </Show>
<Show when={sortKey() !== 'host'}> <Show when={!isResource}>
<button <div class="flex items-center gap-1">
type="button" <span>{col.label}</span>
class="ml-auto rounded bg-gray-200 px-2 py-0.5 text-[10px] font-medium text-gray-700 transition hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700" {colSortKey && renderSortIndicator(colSortKey)}
onClick={(e) => { </div>
e.stopPropagation();
resetHostGrouping();
}}
>
Group by host
</button>
</Show> </Show>
</div> </th>
</Show> );
<Show when={!isResource}> }}
<div class="flex items-center gap-1 overflow-hidden">
<span class="hidden xl:inline truncate">{col.label}</span>
<span class="xl:hidden truncate">{col.shortLabel || col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)}
</div>
</Show>
</div>
);
}}
</For>
</div>
{/* Rows */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<Show
when={isGroupedView()}
fallback={
<For each={sortedRows()}>
{(row) => renderRow(row, false)}
</For> </For>
} </tr>
> </thead>
<For each={orderedGroups()}> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{(group) => ( <Show
<> when={isGroupedView()}
<DockerHostGroupHeader host={group.host} gridTemplate={gridTemplate} visibleColumns={visibleColumns} /> fallback={
<For each={group.rows}>{(row) => renderRow(row, true)}</For> <For each={sortedRows()}>
</> {(row) => renderRow(row, false)}
)} </For>
</For> }
</Show> >
</div> <For each={orderedGroups()}>
{(group) => (
<>
<DockerHostGroupHeader host={group.host} columnCount={DOCKER_COLUMNS.length} />
<For each={group.rows}>{(row) => renderRow(row, true)}</For>
</>
)}
</For>
</Show>
</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,177 +152,46 @@ 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; if (!host.sensors?.temperatureCelsius) return null;
const memPercent = () => host.memory?.usage ?? 0; const temps = host.sensors.temperatureCelsius;
const hostStatus = createMemo(() => getHostStatusIndicator(host)); // Try to find a "package" or "composite" temperature first
const packageKey = Object.keys(temps).find(k =>
k.toLowerCase().includes('package') ||
k.toLowerCase().includes('composite') ||
k.toLowerCase().includes('tctl')
);
switch (column.id) { if (packageKey) return temps[packageKey];
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;
const temps = host.sensors.temperatureCelsius;
// Try to find a "package" or "composite" temperature first // Fallback: average of all core temps
const packageKey = Object.keys(temps).find(k => const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
k.toLowerCase().includes('package') || if (coreKeys.length > 0) {
k.toLowerCase().includes('composite') || const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
k.toLowerCase().includes('tctl') return sum / coreKeys.length;
);
if (packageKey) return temps[packageKey];
// Fallback: average of all core temps
const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
if (coreKeys.length > 0) {
const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
return sum / coreKeys.length;
}
// Fallback: just take the first available value if any
const values = Object.values(temps);
if (values.length > 0) return values[0];
return null;
})();
return (
<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 };
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
return {
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
used: totalUsed,
total: totalSize
};
})();
const diskPercent = diskStats.percent;
return (
<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;
} }
// Fallback: just take the first available value if any
const values = Object.values(temps);
if (values.length > 0) return values[0];
return null;
}; };
const getDiskStats = (host: Host) => {
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
return {
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
used: totalUsed,
total: totalSize
};
};
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
return ( return (
<div class="space-y-4"> <div class="space-y-4">
<Show when={isLoading()}> <Show when={isLoading()}>
@ -397,277 +264,403 @@ 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`}
<For each={visibleColumns()}> onClick={() => handleSort('name')}
{(column) => (
<div
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' : ''}`}
onClick={() => column.sortable && column.sortKey && handleSort(column.sortKey)}
> >
<div class="flex items-center gap-1"> Host {renderSortIndicator('name')}
{column.label} </th>
{column.sortKey === sortKey() && ( <th class={thClass} onClick={() => handleSort('platform')}>
<span class="text-gray-400"> Platform {renderSortIndicator('platform')}
{sortDirection() === 'asc' ? '▲' : '▼'} </th>
</span> <th class={thClass} onClick={() => handleSort('cpu')}>
)} CPU {renderSortIndicator('cpu')}
</div> </th>
</div> <th class={thClass} onClick={() => handleSort('memory')}>
)} Memory {renderSortIndicator('memory')}
</For> </th>
</div> <th class={thClass}>
Temp
</th>
<th class={thClass} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<th class={`${thClass} text-right pr-4`} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={filteredHosts()}>
{(host) => {
// Drawer state
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
{/* Rows */} // Check if we have additional info to show in drawer
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0"> const hasDrawerContent = createMemo(() => {
<For each={filteredHosts()}> return (
{(host) => { (host.disks && host.disks.length > 0) ||
// Drawer state (host.networkInterfaces && host.networkInterfaces.length > 0) ||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); (host.raid && host.raid.length > 0) ||
host.loadAverage ||
host.cpuCount ||
host.kernelVersion ||
host.architecture ||
host.agentVersion ||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
);
});
const toggleDrawer = (event: MouseEvent) => {
if (!hasDrawerContent()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
return;
}
setDrawerOpen((prev) => !prev);
};
// Sync drawer state
createEffect(on(() => host.id, (id) => {
const stored = drawerState.get(id);
if (stored !== undefined) {
setDrawerOpen(stored);
} else {
setDrawerOpen(false);
}
}));
createEffect(() => {
drawerState.set(host.id, drawerOpen());
});
const rowClass = () => {
const base = 'transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
return `${base} ${hover} ${clickable} ${expanded}`;
};
const hostStatus = createMemo(() => getHostStatusIndicator(host));
const cpuPercent = host.cpuUsage ?? 0;
const memPercent = host.memory?.usage ?? 0;
const tempValue = getTemperatureValue(host);
const diskStats = getDiskStats(host);
// Check if we have additional info to show in drawer
const hasDrawerContent = createMemo(() => {
return ( return (
(host.disks && host.disks.length > 0) || <>
(host.networkInterfaces && host.networkInterfaces.length > 0) || <tr
(host.raid && host.raid.length > 0) || class={rowClass()}
host.loadAverage || onClick={toggleDrawer}
host.cpuCount || aria-expanded={drawerOpen()}
host.kernelVersion || >
host.architecture || {/* Host Name */}
host.agentVersion || <td class="pl-4 pr-2 py-1 align-middle">
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0) <div class="flex items-center gap-2 min-w-0">
); <StatusDot
}); variant={hostStatus().variant}
title={hostStatus().label}
const toggleDrawer = (event: MouseEvent) => { ariaLabel={hostStatus().label}
if (!hasDrawerContent()) return; size="xs"
const target = event.target as HTMLElement; />
if (target.closest('a, button, [data-prevent-toggle]')) { <div class="min-w-0">
return; <p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
} {host.displayName || host.hostname || host.id}
setDrawerOpen((prev) => !prev); </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">
// Sync drawer state {host.hostname}
createEffect(on(() => host.id, (id) => { </p>
const stored = drawerState.get(id);
if (stored !== undefined) {
setDrawerOpen(stored);
} else {
setDrawerOpen(false);
}
}));
createEffect(() => {
drawerState.set(host.id, drawerOpen());
});
const rowClass = () => {
const base = 'grid items-center transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
return `${base} ${hover} ${clickable} ${expanded}`;
};
return (
<>
<div
class={rowClass()}
style={{ 'grid-template-columns': gridTemplate() }}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
<For each={visibleColumns()}>
{(column) => renderCell(column, host)}
</For>
</div>
{/* Drawer - Additional Info */}
<Show when={drawerOpen() && hasDrawerContent()}>
<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">
{/* System Info */}
<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="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<Show when={host.cpuCount}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
</div>
</Show> </Show>
<Show when={host.loadAverage && host.loadAverage.length > 0}> <Show when={host.lastSeen}>
<div class="flex items-center justify-between gap-2"> <p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span> Updated {formatRelativeTime(host.lastSeen!)}
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span> </p>
</div>
</Show>
<Show when={host.architecture}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
</div>
</Show>
<Show when={host.kernelVersion}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
</div>
</Show>
<Show when={host.agentVersion}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
</div>
</Show> </Show>
</div> </div>
</div> </div>
</td>
{/* Network Interfaces */} {/* Platform */}
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}> <td class="px-2 py-1 align-middle">
<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="text-xs text-gray-700 dark:text-gray-300">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div> <p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
<div class="mt-2 space-y-2 text-[11px]"> <Show when={host.osName}>
<For each={host.networkInterfaces?.slice(0, 4)}> <p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{(iface) => ( {host.osName} {host.osVersion}
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70"> </p>
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div> </Show>
<Show when={iface.addresses && iface.addresses.length > 0}> </div>
<div class="flex flex-wrap gap-1 mt-1 text-[10px]"> </td>
<For each={iface.addresses}>
{(addr) => ( {/* CPU */}
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"> <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
{addr} <Show when={isMobile()}>
</span> <div class="md:hidden flex justify-center">
)} <MetricText value={cpuPercent} type="cpu" />
</For>
</div>
</Show>
</div>
)}
</For>
</div>
</div> </div>
</Show> </Show>
<div class="hidden md:block">
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</div>
</td>
{/* Disk Info */} {/* Memory */}
<Show when={host.disks && host.disks.length > 0}> <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<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"> <Show when={isMobile()}>
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div> <div class="md:hidden flex justify-center">
<div class="mt-2 space-y-2 text-[11px]"> <MetricText value={memPercent} type="memory" />
<For each={host.disks?.slice(0, 3)}> </div>
{(disk) => { </Show>
const diskPercent = () => disk.usage ?? 0; <div class="hidden md:block">
return ( <StackedMemoryBar
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70"> used={host.memory?.used || 0}
<div class="flex items-center justify-between"> total={host.memory?.total || 0}
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span> balloon={host.memory?.balloon || 0}
<span class="text-[10px] text-gray-500 dark:text-gray-400"> swapUsed={host.memory?.swapUsed || 0}
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)} swapTotal={host.memory?.swapTotal || 0}
</span> />
</div> </div>
<Show when={diskPercent() > 0}> </td>
<div class="mt-1">
<MetricBar {/* Temperature */}
value={diskPercent()} <td class="px-2 py-1 align-middle">
label={formatPercent(diskPercent())} <div class="flex justify-center">
type="disk" <Show when={tempValue !== null} fallback={<span class="text-xs text-gray-400"></span>}>
class="max-w-none" <TemperatureGauge value={tempValue!} />
/> </Show>
</div> </div>
</Show> </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 */}
<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="flex flex-wrap justify-start gap-3">
{/* System Info */}
<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="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<Show when={host.cpuCount}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
</div> </div>
); </Show>
}} <Show when={host.loadAverage && host.loadAverage.length > 0}>
</For> <div class="flex items-center justify-between gap-2">
</div> <span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
</div> <span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
</Show> </div>
</Show>
<Show when={host.architecture}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
</div>
</Show>
<Show when={host.kernelVersion}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
</div>
</Show>
<Show when={host.agentVersion}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
</div>
</Show>
</div>
</div>
{/* Temperature Sensors */} {/* Network Interfaces */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}> <Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
<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="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
{([name, temp]) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
{temp.toFixed(1)}°C
</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* RAID Arrays */}
<Show when={host.raid && host.raid.length > 0}>
<For each={host.raid!}>
{(array) => {
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
const stateColor = () => {
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
if (isHealthy()) return 'text-green-600 dark:text-green-400';
return 'text-gray-600 dark:text-gray-300';
};
return (
<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">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200"> <div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
RAID {array.level.replace('raid', '')} - {array.device} <div class="mt-2 space-y-2 text-[11px]">
</div> <For each={host.networkInterfaces?.slice(0, 4)}>
<div class="mt-2 space-y-1 text-[11px]"> {(iface) => (
<div class="flex items-center justify-between gap-2"> <div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<span class="font-medium text-gray-700 dark:text-gray-200">State</span> <div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
<span class={stateColor()}>{array.state}</span> <Show when={iface.addresses && iface.addresses.length > 0}>
</div> <div class="flex flex-wrap gap-1 mt-1 text-[10px]">
<div class="flex items-center justify-between gap-2"> <For each={iface.addresses}>
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span> {(addr) => (
<span class="text-gray-600 dark:text-gray-300"> <span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{array.activeDevices}/{array.totalDevices} {addr}
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>} </span>
</span> )}
</div> </For>
<Show when={isRebuilding() && array.rebuildPercent > 0}> </div>
<div class="flex items-center justify-between gap-2"> </Show>
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
<span class="text-amber-600 dark:text-amber-400 font-medium">
{array.rebuildPercent.toFixed(1)}%
</span>
</div>
<Show when={array.rebuildSpeed}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
</div> </div>
</Show> )}
</Show> </For>
</div> </div>
</div> </div>
); </Show>
}}
</For> {/* Disk Info */}
</Show> <Show when={host.disks && host.disks.length > 0}>
</div> <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> <div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
</Show> <div class="mt-2 space-y-2 text-[11px]">
</> <For each={host.disks?.slice(0, 3)}>
); {(disk) => {
}} const diskPercent = () => disk.usage ?? 0;
</For> return (
</div> <div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
</span>
</div>
<Show when={diskPercent() > 0}>
<div class="mt-1">
<MetricBar
value={diskPercent()}
label={formatPercent(diskPercent())}
type="disk"
class="max-w-none"
/>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
<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="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
{([name, temp]) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
{temp.toFixed(1)}°C
</span>
</div>
)}
</For>
</div>
</div>
</Show>
{/* RAID Arrays */}
<Show when={host.raid && host.raid.length > 0}>
<For each={host.raid!}>
{(array) => {
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
const stateColor = () => {
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
if (isHealthy()) return 'text-green-600 dark:text-green-400';
return 'text-gray-600 dark:text-gray-300';
};
return (
<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="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
RAID {array.level.replace('raid', '')} - {array.device}
</div>
<div class="mt-2 space-y-1 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
<span class={stateColor()}>{array.state}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
<span class="text-gray-600 dark:text-gray-300">
{array.activeDevices}/{array.totalDevices}
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
</span>
</div>
<Show when={isRebuilding() && array.rebuildPercent > 0}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
<span class="text-amber-600 dark:text-amber-400 font-medium">
{array.rebuildPercent.toFixed(1)}%
</span>
</div>
<Show when={array.rebuildSpeed}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
</div>
</Show>
</Show>
</div>
</div>
);
}}
</For>
</Show>
</div>
</div>
</td>
</tr>
</Show>
</>
);
}}
</For>
</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,225 +330,221 @@ 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 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>
);
}; };
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 (
<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`}
<For each={visibleColumns()}> onClick={() => handleSort('name')}
{(column) => ( >
<HeaderCell column={column} /> {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
)} </th>
</For> <th class={thClass} onClick={() => handleSort('uptime')}>
</div> Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</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()}>
{(item) => {
const isPVEItem = isPVE(item);
const isPBSItem = !isPVEItem;
const node = isPVEItem ? (item as Node) : null;
const pbs = isPBSItem ? (item as PBSInstance) : null;
<div class="divide-y divide-gray-200 dark:divide-gray-700"> const online = isItemOnline(item);
<For each={sortedItems()}> const statusIndicator = createMemo(() =>
{(item) => { isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
const isPVEItem = isPVE(item); );
const isPBSItem = !isPVEItem; const cpuPercentValue = getCpuPercent(item);
const node = isPVEItem ? (item as Node) : null; const memoryPercentValue = getMemoryPercent(item);
const pbs = isPBSItem ? (item as PBSInstance) : null; const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const cpuTemperatureValue = getCpuTemperatureValue(item);
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVEItem) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
const online = isItemOnline(item); const nodeId = isPVEItem ? node!.id : pbs!.name;
const statusIndicator = createMemo(() => const isSelected = () => props.selectedNode === nodeId;
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance), const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
); const metricsKey = buildMetricKey('node', resourceId);
const cpuPercentValue = getCpuPercent(item); const alertStyles = createMemo(() =>
const memoryPercentValue = getMemoryPercent(item); getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
const diskPercentValue = getDiskPercent(item); );
const diskSublabel = getDiskSublabel(item); const showAlertHighlight = createMemo(
const cpuTemperatureValue = getCpuTemperatureValue(item); () => alertStyles().hasUnacknowledgedAlert && online,
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0; );
const displayName = () => {
if (isPVEItem) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
const nodeId = isPVEItem ? node!.id : pbs!.name; const rowStyle = createMemo(() => {
const isSelected = () => props.selectedNode === nodeId; const styles: Record<string, string> = {};
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name; const shadows: string[] = [];
const metricsKey = buildMetricKey('node', resourceId);
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
);
const showAlertHighlight = createMemo(
() => alertStyles().hasUnacknowledgedAlert && online,
);
const rowStyle = createMemo(() => { if (showAlertHighlight()) {
const styles: Record<string, string> = {}; const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
const shadows: string[] = []; shadows.push(`inset 4px 0 0 0 ${color}`);
}
if (showAlertHighlight()) { if (isSelected()) {
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push(`inset 4px 0 0 0 ${color}`); shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
} }
if (isSelected()) { if (shadows.length > 0) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); styles['box-shadow'] = shadows.join(', ');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); }
}
if (shadows.length > 0) { return styles;
styles['box-shadow'] = shadows.join(', '); });
}
return styles; const rowClass = createMemo(() => {
}); const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
const rowClass = createMemo(() => { if (isSelected()) {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`;
}
if (isSelected()) { if (showAlertHighlight()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 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}`;
}
if (showAlertHighlight()) { let className = baseHover;
return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
}
let className = baseHover; if (props.selectedNode && props.selectedNode !== nodeId) {
className += ' opacity-50 hover:opacity-80';
}
if (props.selectedNode && props.selectedNode !== nodeId) { if (!online) {
className += ' opacity-50 hover:opacity-80'; className += ' opacity-60';
} }
if (!online) { return className;
className += ' opacity-60'; });
}
return className; return (
}); <tr
class={rowClass()}
const cellBgClass = createMemo(() => { style={rowStyle()}
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30'; onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
if (showAlertHighlight()) { >
return alertStyles().severity === 'critical' {/* Name */}
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40' <td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30'; <div class="flex items-center gap-1.5">
} <StatusDot
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50'; variant={statusIndicator().variant}
}); title={statusIndicator().label}
ariaLabel={statusIndicator().label}
// Render cell content based on column type size="xs"
const renderCell = (column: ColumnDef) => { />
const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`; <a
const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start'; href={
isPVEItem
switch (column.id) { ? node!.guestURL || node!.host || `https://${node!.name}:8006`
case 'name': : pbs!.host || `https://${pbs!.name}:8007`
return ( }
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}> target="_blank"
<div class="flex items-center gap-1.5"> onClick={(e) => e.stopPropagation()}
<StatusDot class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 whitespace-nowrap"
variant={statusIndicator().variant} title={displayName()}
title={statusIndicator().label} >
ariaLabel={statusIndicator().label} {displayName()}
size="xs" </a>
/> <Show when={showActualName()}>
<a <span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
href={ ({(node as Node).name})
isPVEItem </span>
? node!.guestURL || node!.host || `https://${node!.name}:8006` </Show>
: pbs!.host || `https://${pbs!.name}:8007` <div class="hidden xl:flex items-center gap-1.5 ml-1">
} <Show when={isPVEItem}>
target="_blank" <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">
onClick={(e) => e.stopPropagation()} PVE
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate" </span>
title={displayName()} </Show>
> <Show when={isPVEItem && node!.pveVersion}>
{displayName()} <span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
</a> v{node!.pveVersion.split('/')[1] || node!.pveVersion}
<Show when={showActualName()}> </span>
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate"> </Show>
({(node as Node).name}) <Show when={isPVEItem && node!.isClusterMember !== undefined}>
<span
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-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
<Show when={isPBSItem}>
<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
</span>
</Show>
<Show when={isPBSItem && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
v{pbs!.version}
</span> </span>
</Show> </Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1">
<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">
PVE
</span>
</Show>
<Show when={isPVEItem && node!.pveVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span>
</Show>
<Show when={isPVEItem && node!.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
? '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'
}`}
>
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
<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">
PBS
</span>
</Show>
<Show when={isPBSItem && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{pbs!.version}
</span>
</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,169 +556,182 @@ 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 flex justify-center">
<div class="md:hidden w-full"> <MetricText value={cpuPercentValue} type="cpu" />
<MetricText value={cpuPercentValue} type="cpu" />
</div>
</Show>
<div class="hidden md:block w-full">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={cpuPercentValue}
type="cpu"
resourceId={metricsKey}
isRunning={online}
showMobile={false}
class="w-full"
/>
}>
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={node!.loadAverage?.[0]}
cores={node!.cpuInfo?.cores}
model={node!.cpuInfo?.model}
resourceId={metricsKey}
/>
</Show>
</div> </div>
</div> </Show>
); <div class="hidden md:block">
<Show when={isPVEItem} fallback={
case 'memory': <ResponsiveMetricCell
return ( value={cpuPercentValue}
<div class={`${baseCellClass} ${alignClass}`}> type="cpu"
<Show when={isMobile()}> resourceId={metricsKey}
<div class="md:hidden w-full"> isRunning={online}
<MetricText value={memoryPercentValue} type="memory" /> showMobile={false}
</div> />
}>
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={node!.loadAverage?.[0]}
cores={node!.cpuInfo?.cores}
model={node!.cpuInfo?.model}
resourceId={metricsKey}
/>
</Show> </Show>
<div class="hidden md:block w-full"> </div>
<Show when={isPVEItem} fallback={ </td>
<ResponsiveMetricCell
value={memoryPercentValue} {/* Memory */}
type="memory" <td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
resourceId={metricsKey} <Show when={isMobile()}>
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined} <div class="md:hidden flex justify-center">
isRunning={online} <MetricText value={memoryPercentValue} type="memory" />
showMobile={false}
class="w-full"
/>
}>
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
/>
</Show>
</div> </div>
</Show>
<div class="hidden md:block">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
isRunning={online}
showMobile={false}
/>
}>
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
/>
</Show>
</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" resourceId={metricsKey}
resourceId={metricsKey} sublabel={diskSublabel}
sublabel={diskSublabel} isRunning={online}
isRunning={online} showMobile={isMobile()}
showMobile={isMobile()} />
class="w-full" </td>
/>
</div>
);
case 'temperature': {/* Temperature */}
return ( <Show when={hasAnyTemperatureData()}>
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}> <td class="px-2 py-1 align-middle">
<Show <div class="flex justify-center">
when={ <Show
online && when={
isPVEItem && online &&
cpuTemperatureValue !== null && isPVEItem &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) && cpuTemperatureValue !== null &&
isTemperatureMonitoringEnabled(node!) (node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
} isTemperatureMonitoringEnabled(node!)
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>}
{(() => { >
const value = cpuTemperatureValue as number; {(() => {
const temp = node!.temperature; const value = cpuTemperatureValue as number;
const cpuMinValue = const temp = node!.temperature;
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null; const cpuMinValue =
const cpuMaxValue = typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0 const cpuMaxValue =
? temp.cpuMaxRecord typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
: null; ? temp.cpuMaxRecord
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null; : null;
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
const gpus = temp?.gpu ?? []; const gpus = temp?.gpu ?? [];
const hasGPU = gpus.length > 0; const hasGPU = gpus.length > 0;
if (hasMinMax || hasGPU) { if (hasMinMax || hasGPU) {
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined; const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined; const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
return (
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
<TemperatureGauge
value={value}
min={min}
max={max}
/>
</div>
);
}
return ( return (
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}> <TemperatureGauge value={value} />
<TemperatureGauge
value={value}
min={min}
max={max}
/>
</div>
); );
} })()}
</Show>
return (
<TemperatureGauge value={value} />
);
})()}
</Show>
</div>
);
// Count columns (vmCount, containerCount, storageCount, diskCount, backupCount)
default:
if (column.id.endsWith('Count')) {
const value = getCountValue(item, column.id as CountSortKey);
const display = online ? value ?? '-' : '-';
const textClass = online
? 'text-xs text-gray-700 dark:text-gray-300'
: 'text-xs text-gray-400 dark:text-gray-500';
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<span class={textClass}>{display}</span>
</div> </div>
); </td>
} </Show>
return null;
}
};
return ( {/* Dashboard tab: VMs and CTs */}
<div <Show when={props.currentTab === 'dashboard'}>
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, 'vmCount') ?? '-' : '-'}
<For each={visibleColumns()}> </span>
{(column) => renderCell(column)} </div>
</For> </td>
</div> <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'}>
</For > {online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
</div > </span>
</div > </div>
</Card > </td>
</Show>
{/* Storage tab: Storage and Disks */}
<Show when={props.currentTab === 'storage'}>
<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, 'storageCount') ?? '-' : '-'}
</span>
</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>
</tbody>
</table>
</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 */} {Math.round(props.value)}°C
<span class={`text-xs font-medium w-[36px] text-right ${textColorClass()}`}> </span>
{Math.round(props.value)}°C
</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(() => {