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 type { VM, Container, Node } from '@/types/api';
import { GuestRow, GUEST_COLUMNS } from './GuestRow';
import { useGridTemplate } from '@/components/shared/responsive';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
@ -259,9 +258,8 @@ export function Dashboard(props: DashboardProps) {
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Use the same grid template as GuestRow for header alignment
const { gridTemplate: headerGridTemplate } = useGridTemplate({ columns: GUEST_COLUMNS });
// Total columns for colspan calculations
const totalColumns = GUEST_COLUMNS.length;
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
@ -929,165 +927,148 @@ export function Dashboard(props: DashboardProps) {
{/* Table View */}
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
<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">
{/* Desktop Header */}
<div
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"
style={{ 'grid-template-columns': headerGridTemplate() }}
>
{/* Name Header */}
<div
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' ? '▲' : '▼')}
</div>
<table class="w-full border-collapse whitespace-nowrap">
<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-700">
{/* Name Header */}
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Type */}
<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('type')}
title="Type"
>
<span class="hidden xl:inline">Type</span>
<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>
{/* Type */}
<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('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Guest List */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<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!} />
</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>
</div>
{/* VMID */}
<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('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Uptime */}
<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('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* CPU */}
<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('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Memory */}
<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('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk */}
<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('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Read */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Write */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net In */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net Out */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
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>
</Card>
</ComponentErrorBoundary>

View file

@ -1,5 +1,5 @@
import { GuestDrawer } from './GuestDrawer';
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
import { TagBadges } from './TagBadges';
@ -13,7 +13,8 @@ import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys';
import { type ColumnPriority } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { useBreakpoint } from '@/hooks/useBreakpoint';
type Guest = VM | Container;
@ -113,19 +114,19 @@ interface ColumnDef {
}
export const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', maxWidth: '300px' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' },
// Metric columns - fixed width to match progress bar max-width (140px + padding)
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
// I/O columns - fixed width
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
{ id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
// Metric columns - fixed min/max width to match progress bar
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
// I/O columns - auto sizing
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
];
interface GuestRowProps {
@ -156,8 +157,8 @@ export function GuestRow(props: GuestRowProps) {
const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
// Use the responsive grid template hook for dynamic column visibility
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS });
// Use breakpoint hook directly for responsive behavior
const { isMobile } = useBreakpoint();
// Create namespaced metrics key
const metricsKey = createMemo(() => {
@ -485,142 +486,146 @@ export function GuestRow(props: GuestRowProps) {
};
});
// Render cell content based on column type
const renderCell = (column: ColumnDef) => {
switch (column.id) {
case 'name':
return (
<div class={`px-1 py-1 flex items-center min-w-0 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<div class="flex items-center gap-2 min-w-0 w-full">
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<StatusDot
variant={guestStatus().variant}
title={guestStatus().label}
ariaLabel={guestStatus().label}
size="xs"
/>
<div class="flex-1 min-w-0">
<Show
when={isEditingUrl()}
fallback={
<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 overflow-hidden text-ellipsis whitespace-nowrap"
style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-guest-name-editable
// Total columns for colspan calculation
const totalColumns = GUEST_COLUMNS.length;
return (
<>
<tr
class={rowClass()}
style={rowStyle()}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<StatusDot
variant={guestStatus().variant}
title={guestStatus().label}
ariaLabel={guestStatus().label}
size="xs"
/>
<Show
when={isEditingUrl()}
fallback={
<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}
</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"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</div>
}
>
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
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}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</div>
}
>
<div class="flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
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="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>
<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>
);
case 'type':
return (
<div class="px-0.5 py-1 flex justify-center items-center">
<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}
/>
</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
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-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'}
</span>
</div>
);
</td>
case 'vmid':
return (
<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">
{/* VMID */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
{props.guest.vmid}
</div>
);
</td>
case 'uptime':
return (
<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={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
{/* Uptime */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<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={isMobile()} fallback={formatUptime(props.guest.uptime)}>
{formatUptime(props.guest.uptime, true)}
</Show>
</Show>
</div>
</span>
</div>
);
</td>
case 'cpu':
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">
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<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
value={cpuPercent()}
type="cpu"
@ -664,134 +685,111 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
isRunning={isRunning()}
showMobile={isMobile()}
class="w-full"
showMobile={false}
/>
</div>
);
</td>
case 'memory':
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">
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
<Show when={isMobile()}>
<div class="md:hidden">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
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}
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div title={memoryTooltip() ?? undefined}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={true}
/>
</div>
</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>
);
</td>
case 'diskRead':
return (
<div class="py-1 flex justify-center items-center min-h-[24px]">
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<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>}>
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
</Show>
</div>
);
</td>
case 'diskWrite':
return (
<div class="py-1 flex justify-center items-center min-h-[24px]">
{/* Disk Write */}
<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>}>
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
</Show>
</div>
);
</td>
case 'netIn':
return (
<div class="py-1 flex justify-center items-center min-h-[24px]">
{/* Net In */}
<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>}>
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
</Show>
</div>
);
</td>
case 'netOut':
return (
<div class="py-1 flex justify-center items-center min-h-[24px]">
{/* Net Out */}
<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>}>
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
</Show>
</div>
);
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>
</td>
</tr>
<Show when={drawerOpen() && canShowDrawer()}>
<div class="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<div class="p-4">
<tr class="bg-gray-50 dark:bg-gray-900/50">
<td colspan={totalColumns} class="p-4 border-b border-gray-200 dark:border-gray-700">
<GuestDrawer
guest={props.guest}
metricsKey={metricsKey()}
onClose={() => setCurrentlyExpandedGuestId(null)}
/>
</div>
</div>
</td>
</tr>
</Show>
</>
);

View file

@ -136,12 +136,12 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="300px" persistKey="docker-host-summary">
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
<ScrollableTable persistKey="docker-host-summary">
<table class="w-full border-collapse whitespace-nowrap">
<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
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')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabIndex={0}
@ -150,47 +150,47 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
>
Host {renderSortIndicator('name')}
</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
</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')}
>
CPU {renderSortIndicator('cpu')}
</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')}
>
Memory {renderSortIndicator('memory')}
</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')}
>
Disk {renderSortIndicator('disk')}
</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')}
>
Containers {renderSortIndicator('running')}
</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')}
>
Uptime {renderSortIndicator('uptime')}
</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')}
>
Last Update {renderSortIndicator('lastSeen')}
</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')}
>
Agent {renderSortIndicator('agent')}
@ -278,14 +278,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</div>
</div>
</td>
<td class="px-0.5 md:px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full whitespace-nowrap">
{renderDockerStatusBadge(summary.host.status)}
</div>
</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()}>
<div class="md:hidden">
<div class="md:hidden flex justify-center">
<MetricText value={summary.cpuPercent} type="cpu" />
</div>
</Show>
@ -298,7 +298,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
/>
</div>
</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
value={summary.memoryPercent}
type="memory"
@ -308,7 +308,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
showMobile={isMobile()}
/>
</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
value={summary.diskPercent}
type="disk"
@ -318,8 +318,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
showMobile={isMobile()}
/>
</td>
<td class="px-0.5 md:px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full px-2">
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
<Show
when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
@ -333,14 +333,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</Show>
</div>
</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">
<span class="text-xs text-gray-600 dark:text-gray-400">
{uptimeLabel}
</span>
</div>
</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">
<Show
when={summary.lastSeenRelative}
@ -357,7 +357,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</Show>
</div>
</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">
<Show
when={summary.host.agentVersion}

View file

@ -21,7 +21,8 @@ import {
getDockerServiceStatusIndicator,
} from '@/utils/status';
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 type { ColumnConfig } from '@/types/responsive';
@ -123,16 +124,16 @@ interface DockerColumnDef extends ColumnConfig {
// - supplementary: Visible on large screens and up (lg: 1024px+)
// - detailed: Visible on extra large screens and up (xl: 1280px+)
export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: '100px', flex: 1, sortKey: 'resource' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '50px', maxWidth: '80px', sortKey: 'type' },
{ id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'essential', minWidth: '80px', maxWidth: '250px', sortKey: 'image' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: '80px', maxWidth: '130px', sortKey: 'status' },
// Metric columns - fixed width to match progress bar max-width (140px + padding)
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type' },
{ id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'image' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status' },
// Metric columns - need fixed width to match progress bar max-width (140px + padding)
// Note: Disk column removed - Docker API rarely provides this data
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'memory' },
{ id: 'tasks', label: 'Tasks', shortLabel: 'Tasks', priority: 'essential', minWidth: '60px', maxWidth: '80px', sortKey: 'tasks' },
{ id: 'updated', label: 'Uptime', shortLabel: 'Uptime', priority: 'essential', minWidth: '70px', maxWidth: '90px', sortKey: 'updated' },
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory' },
{ id: 'tasks', label: 'Tasks', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'tasks' },
{ 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)
@ -732,39 +733,38 @@ const UNGROUPED_RESOURCE_INDENT = 'pl-4 sm:pl-5 lg:pl-6';
const DockerHostGroupHeader: Component<{
host: DockerHost;
gridTemplate: Accessor<string>;
visibleColumns: Accessor<ColumnConfig[]>;
columnCount: number;
}> = (props) => {
const displayName = getHostDisplayName(props.host);
const hostStatus = () => getDockerHostStatusIndicator(props.host);
const isOnline = () => hostStatus().variant === 'success';
return (
<div class="bg-gray-50 dark:bg-gray-900/40 py-0.5 pr-2 pl-4">
<div
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
title={hostStatus().label}
>
<StatusDot
variant={hostStatus().variant}
<tr class="bg-gray-50 dark:bg-gray-900/40">
<td colspan={props.columnCount} class="py-0.5 pr-2 pl-4">
<div
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span>{displayName}</span>
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
({props.host.hostname})
</span>
</Show>
</div>
</div>
>
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<span>{displayName}</span>
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
({props.host.hostname})
</span>
</Show>
</div>
</td>
</tr>
);
};
const DockerContainerRow: Component<{
row: Extract<DockerRow, { kind: 'container' }>;
visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>;
customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
@ -1172,14 +1172,14 @@ const DockerContainerRow: Component<{
);
case 'image':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate overflow-hidden">
<span title={container.image}>{container.image || '—'}</span>
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{container.image || '—'}
</div>
);
case 'status':
return (
<div class="px-2 py-0.5 text-xs overflow-hidden">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap block truncate ${statusBadgeClass()}`}>{statusLabel()}</span>
<div class="px-2 py-0.5 text-xs whitespace-nowrap">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass()}`}>{statusLabel()}</span>
</div>
);
case 'cpu':
@ -1270,24 +1270,32 @@ const DockerContainerRow: Component<{
return (
<>
<div
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' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
<tr
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' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<For each={props.visibleColumns()}>
{(column) => renderCell(column)}
<For each={DOCKER_COLUMNS}>
{(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>
</div>
</tr>
<Show when={expanded() && hasDrawerContent()}>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="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">
Summary
</div>
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="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="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
@ -1614,9 +1622,11 @@ const DockerContainerRow: Component<{
})}
</div>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>
</td>
</tr>
</Show>
</>
);
@ -1624,8 +1634,6 @@ const DockerContainerRow: Component<{
const DockerServiceRow: Component<{
row: Extract<DockerRow, { kind: 'service' }>;
visibleColumns: Accessor<ColumnConfig[]>;
gridTemplate: Accessor<string>;
isMobile: Accessor<boolean>;
customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
@ -1923,8 +1931,8 @@ const DockerServiceRow: Component<{
);
case 'image':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate">
<span title={service.image}>{service.image || '—'}</span>
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{service.image || '—'}
</div>
);
case 'status':
@ -1967,29 +1975,37 @@ const DockerServiceRow: Component<{
return (
<>
<div
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' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
<tr
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' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<For each={props.visibleColumns()}>
{(column) => renderCell(column)}
<For each={DOCKER_COLUMNS}>
{(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>
</div>
</tr>
<Show when={expanded() && hasTasks()}>
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="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="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
<span>Tasks</span>
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
</span>
</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">
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
<span>Tasks</span>
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
</span>
</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">
<tr>
<th class="py-1 pr-2 text-left font-medium">Task</th>
@ -2095,11 +2111,13 @@ const DockerServiceRow: Component<{
}}
</For>
</tbody>
</table>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
</Show>
</>
);
@ -2114,8 +2132,8 @@ const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => {
};
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the responsive grid template hook for dynamic column visibility
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS });
// Use the breakpoint hook for responsive behavior
const { isMobile } = useBreakpoint();
// Caches for stable object references to prevent re-animations
const rowCache = new Map<string, DockerRow>();
@ -2442,8 +2460,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return row.kind === 'container' ? (
<DockerContainerRow
row={row}
visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile}
customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate}
@ -2453,8 +2469,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
) : (
<DockerServiceRow
row={row}
visibleColumns={visibleColumns}
gridTemplate={gridTemplate}
isMobile={isMobile}
customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate}
@ -2485,80 +2499,78 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
>
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
{/* Header Row */}
<div
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"
style={{ 'grid-template-columns': gridTemplate() }}
>
<For each={visibleColumns()}>
{(column) => {
const col = column as DockerColumnDef;
const colSortKey = col.sortKey as SortKey | undefined;
const isResource = col.id === 'resource';
return (
<div
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)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0}
role="button"
aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
>
<Show when={isResource}>
<div class="flex flex-wrap items-center gap-2">
<span>{col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)}
<Show when={sortKey() === 'host'}>
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span>
<table class="w-full border-collapse whitespace-nowrap">
<thead>
<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">
<For each={DOCKER_COLUMNS}>
{(column) => {
const col = column as DockerColumnDef;
const colSortKey = col.sortKey as SortKey | undefined;
const isResource = col.id === 'resource';
return (
<th
class={`${isResource ? 'pl-4 sm:pl-5 lg:pl-6 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 text-left font-medium whitespace-nowrap`}
style={{ "min-width": col.id === 'cpu' || col.id === 'memory' ? '140px' : undefined }}
onClick={() => colSortKey && handleSort(colSortKey)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0}
role="button"
aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
>
<Show when={isResource}>
<div class="flex flex-wrap items-center gap-2">
<span>{col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)}
<Show when={sortKey() === 'host'}>
<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 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 when={!isResource}>
<div class="flex items-center gap-1">
<span>{col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)}
</div>
</Show>
</div>
</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)}
</th>
);
}}
</For>
}
>
<For each={orderedGroups()}>
{(group) => (
<>
<DockerHostGroupHeader host={group.host} gridTemplate={gridTemplate} visibleColumns={visibleColumns} />
<For each={group.rows}>{(row) => renderRow(row, true)}</For>
</>
)}
</For>
</Show>
</div>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<Show
when={isGroupedView()}
fallback={
<For each={sortedRows()}>
{(row) => renderRow(row, false)}
</For>
}
>
<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>
</Card>

View file

@ -11,27 +11,16 @@ import { HostsFilter } from './HostsFilter';
import { useWebSocket } from '@/App';
import { StatusDot } from '@/components/shared/StatusDot';
import { getHostStatusIndicator } from '@/utils/status';
import { MetricText, useGridTemplate } from '@/components/shared/responsive';
import { MetricText } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import type { ColumnConfig } from '@/types/responsive';
import { STANDARD_COLUMNS } from '@/types/responsive';
import { useBreakpoint } from '@/hooks/useBreakpoint';
// Global drawer state to persist across re-renders
const drawerState = new Map<string, boolean>();
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime';
const HOST_COLUMNS: ColumnConfig[] = [
{ ...STANDARD_COLUMNS.name, label: 'Host', minWidth: '200px', sortKey: 'name' },
{ id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' },
{ ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' },
{ ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' },
{ id: 'temperature', label: 'Temp', priority: 'secondary', minWidth: '80px', maxWidth: '100px' },
{ id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' },
{ ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' },
];
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime';
interface HostsOverviewProps {
hosts: Host[];
@ -45,18 +34,21 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const [sortKey, setSortKey] = createSignal<SortKey>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const { isMobile } = useBreakpoint();
const handleSort = (key: string) => {
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key as SortKey);
setSortKey(key);
setSortDirection('asc');
}
};
// Use responsive grid template
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS });
const renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼';
};
// Keyboard listener to auto-focus search
let searchInputRef: HTMLInputElement | undefined;
@ -116,6 +108,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
case 'memory':
comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0);
break;
case 'disk': {
const aDisk = a.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0;
const bDisk = b.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0;
comparison = aDisk - bDisk;
break;
}
case 'uptime':
comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0);
break;
@ -154,177 +152,46 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host));
});
const renderCell = (column: ColumnConfig, host: Host) => {
const cpuPercent = () => host.cpuUsage ?? 0;
const memPercent = () => host.memory?.usage ?? 0;
const getTemperatureValue = (host: Host) => {
if (!host.sensors?.temperatureCelsius) return null;
const temps = host.sensors.temperatureCelsius;
const hostStatus = createMemo(() => getHostStatusIndicator(host));
// Try to find a "package" or "composite" temperature first
const packageKey = Object.keys(temps).find(k =>
k.toLowerCase().includes('package') ||
k.toLowerCase().includes('composite') ||
k.toLowerCase().includes('tctl')
);
switch (column.id) {
case 'name':
return (
<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;
if (packageKey) return temps[packageKey];
// Try to find a "package" or "composite" temperature first
const packageKey = Object.keys(temps).find(k =>
k.toLowerCase().includes('package') ||
k.toLowerCase().includes('composite') ||
k.toLowerCase().includes('tctl')
);
if (packageKey) return temps[packageKey];
// Fallback: average of all core temps
const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
if (coreKeys.length > 0) {
const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
return sum / coreKeys.length;
}
// Fallback: just take the first available value if any
const values = Object.values(temps);
if (values.length > 0) return values[0];
return null;
})();
return (
<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: average of all core temps
const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
if (coreKeys.length > 0) {
const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
return sum / coreKeys.length;
}
// Fallback: just take the first available value if any
const values = Object.values(temps);
if (values.length > 0) return values[0];
return null;
};
const getDiskStats = (host: Host) => {
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
return {
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
used: totalUsed,
total: totalSize
};
};
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
return (
<div class="space-y-4">
<Show when={isLoading()}>
@ -397,277 +264,403 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
>
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
{/* Header */}
<div
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"
style={{ 'grid-template-columns': gridTemplate() }}
>
<For each={visibleColumns()}>
{(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)}
<table class="w-full border-collapse whitespace-nowrap">
<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-700">
<th
class={`${thClass} text-left pl-4`}
onClick={() => handleSort('name')}
>
<div class="flex items-center gap-1">
{column.label}
{column.sortKey === sortKey() && (
<span class="text-gray-400">
{sortDirection() === 'asc' ? '▲' : '▼'}
</span>
)}
</div>
</div>
)}
</For>
</div>
Host {renderSortIndicator('name')}
</th>
<th class={thClass} onClick={() => handleSort('platform')}>
Platform {renderSortIndicator('platform')}
</th>
<th class={thClass} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<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 */}
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
<For each={filteredHosts()}>
{(host) => {
// Drawer state
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
// Check if we have additional info to show in drawer
const hasDrawerContent = createMemo(() => {
return (
(host.disks && host.disks.length > 0) ||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
(host.raid && host.raid.length > 0) ||
host.loadAverage ||
host.cpuCount ||
host.kernelVersion ||
host.architecture ||
host.agentVersion ||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
);
});
const toggleDrawer = (event: MouseEvent) => {
if (!hasDrawerContent()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
return;
}
setDrawerOpen((prev) => !prev);
};
// Sync drawer state
createEffect(on(() => host.id, (id) => {
const stored = drawerState.get(id);
if (stored !== undefined) {
setDrawerOpen(stored);
} else {
setDrawerOpen(false);
}
}));
createEffect(() => {
drawerState.set(host.id, drawerOpen());
});
const rowClass = () => {
const base = 'transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
return `${base} ${hover} ${clickable} ${expanded}`;
};
const hostStatus = createMemo(() => getHostStatusIndicator(host));
const cpuPercent = host.cpuUsage ?? 0;
const memPercent = host.memory?.usage ?? 0;
const tempValue = getTemperatureValue(host);
const diskStats = getDiskStats(host);
// Check if we have additional info to show in drawer
const hasDrawerContent = createMemo(() => {
return (
(host.disks && host.disks.length > 0) ||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
(host.raid && host.raid.length > 0) ||
host.loadAverage ||
host.cpuCount ||
host.kernelVersion ||
host.architecture ||
host.agentVersion ||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
);
});
const toggleDrawer = (event: MouseEvent) => {
if (!hasDrawerContent()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle]')) {
return;
}
setDrawerOpen((prev) => !prev);
};
// Sync drawer state
createEffect(on(() => host.id, (id) => {
const stored = drawerState.get(id);
if (stored !== undefined) {
setDrawerOpen(stored);
} else {
setDrawerOpen(false);
}
}));
createEffect(() => {
drawerState.set(host.id, drawerOpen());
});
const rowClass = () => {
const base = 'grid items-center transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
return `${base} ${hover} ${clickable} ${expanded}`;
};
return (
<>
<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>
<>
<tr
class={rowClass()}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
{/* Host Name */}
<td class="pl-4 pr-2 py-1 align-middle">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
variant={hostStatus().variant}
title={hostStatus().label}
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{host.displayName || host.hostname || host.id}
</p>
<Show when={host.displayName && host.displayName !== host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.hostname}
</p>
</Show>
<Show when={host.loadAverage && host.loadAverage.length > 0}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
</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 when={host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
Updated {formatRelativeTime(host.lastSeen!)}
</p>
</Show>
</div>
</div>
</td>
{/* Network Interfaces */}
<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">Network</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.networkInterfaces?.slice(0, 4)}>
{(iface) => (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
<Show when={iface.addresses && iface.addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
<For each={iface.addresses}>
{(addr) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{addr}
</span>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</div>
{/* Platform */}
<td class="px-2 py-1 align-middle">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
<Show when={host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{host.osName} {host.osVersion}
</p>
</Show>
</div>
</td>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={cpuPercent} type="cpu" />
</div>
</Show>
<div class="hidden md:block">
<EnhancedCPUBar
usage={cpuPercent}
loadAverage={host.loadAverage?.[0]}
cores={host.cpuCount}
/>
</div>
</td>
{/* Disk Info */}
<Show when={host.disks && host.disks.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">Disks</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.disks?.slice(0, 3)}>
{(disk) => {
const diskPercent = () => disk.usage ?? 0;
return (
<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>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={memPercent} type="memory" />
</div>
</Show>
<div class="hidden md:block">
<StackedMemoryBar
used={host.memory?.used || 0}
total={host.memory?.total || 0}
balloon={host.memory?.balloon || 0}
swapUsed={host.memory?.swapUsed || 0}
swapTotal={host.memory?.swapTotal || 0}
/>
</div>
</td>
{/* Temperature */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-400"></span>}>
<TemperatureGauge value={tempValue!} />
</Show>
</div>
</td>
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={diskStats.percent} type="disk" />
</div>
</Show>
<div class="hidden md:block">
<StackedDiskBar
disks={host.disks}
aggregateDisk={{
total: diskStats.total,
used: diskStats.used,
free: diskStats.total - diskStats.used,
usage: diskStats.percent / 100
}}
/>
</div>
</td>
{/* Uptime */}
<td class="px-2 py-1 pr-4 align-middle">
<div class="flex justify-end">
<Show
when={host.uptimeSeconds}
fallback={<span class="text-xs text-gray-400"></span>}
>
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatUptime(host.uptimeSeconds!)}
</span>
</Show>
</div>
</td>
</tr>
{/* Drawer - Additional Info */}
<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>
);
}}
</For>
</div>
</div>
</Show>
</Show>
<Show when={host.loadAverage && host.loadAverage.length > 0}>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
</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 */}
<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 (
{/* Network Interfaces */}
<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">
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 class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.networkInterfaces?.slice(0, 4)}>
{(iface) => (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
<Show when={iface.addresses && iface.addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
<For each={iface.addresses}>
{(addr) => (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{addr}
</span>
)}
</For>
</div>
</Show>
</div>
</Show>
</Show>
)}
</For>
</div>
</div>
);
}}
</For>
</Show>
</div>
</div>
</Show>
</>
);
}}
</For>
</div>
</Show>
{/* Disk Info */}
<Show when={host.disks && host.disks.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">Disks</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.disks?.slice(0, 3)}>
{(disk) => {
const diskPercent = () => disk.usage ?? 0;
return (
<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>
</Card>
</Show>

View file

@ -10,92 +10,11 @@ import { useAlertsActivation } from '@/stores/alertsActivation';
import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
import { type ColumnPriority } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
// Icons for mobile headers
const ClockIcon = (props: { class?: string }) => (
<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'
};
import { useBreakpoint } from '@/hooks/useBreakpoint';
interface NodeSummaryTableProps {
nodes: Node[];
@ -114,6 +33,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const { activeAlerts, state } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const { isMobile } = useBreakpoint();
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
@ -140,36 +60,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
| 'temperature'
| CountSortKey;
interface CountColumn {
header: string;
key: CountSortKey;
icon: Component<{ class?: string }>;
minWidth?: string;
maxWidth?: string;
}
const [sortKey, setSortKey] = createSignal<SortKey>('default');
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(() => {
return (
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 vmCountsByNode = createMemo<Record<string, number>>(() => {
@ -465,225 +330,221 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
});
});
// Header cell component
const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => {
const Icon = cellProps.column.icon;
const isSorted = () => sortKey() === cellProps.column.id;
const sortIndicator = () => isSorted() ? (sortDirection() === 'asc' ? '▲' : '▼') : '';
const alignClass = () => {
if (cellProps.column.align === 'center') return 'justify-center text-center';
if (cellProps.column.align === 'right') return 'justify-end text-right';
return 'justify-start text-left';
};
const baseClass = `px-0.5 md:px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full ${alignClass()}`;
const nameClass = cellProps.column.id === 'name' ? 'pl-3' : '';
return (
<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 renderSortIndicator = (key: SortKey) => {
if (sortKey() !== key) return null;
return sortDirection() === 'asc' ? '▲' : '▼';
};
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div>
{/* Header */}
<div
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"
style={{ 'grid-template-columns': gridTemplate() }}
>
<For each={visibleColumns()}>
{(column) => (
<HeaderCell column={column} />
)}
</For>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap">
<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-700">
<th
class={`${thClass} text-left pl-3`}
onClick={() => handleSort('name')}
>
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
</th>
<th class={thClass} onClick={() => handleSort('uptime')}>
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">
<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;
const online = isItemOnline(item);
const statusIndicator = createMemo(() =>
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
);
const cpuPercentValue = getCpuPercent(item);
const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const cpuTemperatureValue = getCpuTemperatureValue(item);
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVEItem) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
const online = isItemOnline(item);
const statusIndicator = createMemo(() =>
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
);
const cpuPercentValue = getCpuPercent(item);
const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const cpuTemperatureValue = getCpuTemperatureValue(item);
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVEItem) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
const nodeId = isPVEItem ? node!.id : pbs!.name;
const isSelected = () => props.selectedNode === nodeId;
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
const metricsKey = buildMetricKey('node', resourceId);
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
);
const showAlertHighlight = createMemo(
() => alertStyles().hasUnacknowledgedAlert && online,
);
const nodeId = isPVEItem ? node!.id : pbs!.name;
const isSelected = () => props.selectedNode === nodeId;
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
const metricsKey = buildMetricKey('node', resourceId);
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
);
const showAlertHighlight = createMemo(
() => alertStyles().hasUnacknowledgedAlert && online,
);
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
const shadows: string[] = [];
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
const shadows: string[] = [];
if (showAlertHighlight()) {
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
shadows.push(`inset 4px 0 0 0 ${color}`);
}
if (showAlertHighlight()) {
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
shadows.push(`inset 4px 0 0 0 ${color}`);
}
if (isSelected()) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
}
if (isSelected()) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
}
if (shadows.length > 0) {
styles['box-shadow'] = shadows.join(', ');
}
if (shadows.length > 0) {
styles['box-shadow'] = shadows.join(', ');
}
return styles;
});
return styles;
});
const rowClass = createMemo(() => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
const rowClass = createMemo(() => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`;
}
if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;
}
if (showAlertHighlight()) {
const alertBg = alertStyles().severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20';
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm group ${alertBg}`;
}
if (showAlertHighlight()) {
return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
}
let className = baseHover;
let className = baseHover;
if (props.selectedNode && props.selectedNode !== nodeId) {
className += ' opacity-50 hover:opacity-80';
}
if (props.selectedNode && props.selectedNode !== nodeId) {
className += ' opacity-50 hover:opacity-80';
}
if (!online) {
className += ' opacity-60';
}
if (!online) {
className += ' opacity-60';
}
return className;
});
return className;
});
const cellBgClass = createMemo(() => {
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30';
if (showAlertHighlight()) {
return alertStyles().severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40'
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30';
}
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50';
});
// Render cell content based on column type
const renderCell = (column: ColumnDef) => {
const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`;
const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start';
switch (column.id) {
case 'name':
return (
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
title={statusIndicator().label}
ariaLabel={statusIndicator().label}
size="xs"
/>
<a
href={
isPVEItem
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"
onClick={(e) => e.stopPropagation()}
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate"
title={displayName()}
>
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
({(node as Node).name})
return (
<tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
title={statusIndicator().label}
ariaLabel={statusIndicator().label}
size="xs"
/>
<a
href={
isPVEItem
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"
onClick={(e) => e.stopPropagation()}
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 whitespace-nowrap"
title={displayName()}
>
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
({(node as Node).name})
</span>
</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 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 whitespace-nowrap">
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span>
</Show>
<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>
</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>
);
</td>
case 'uptime':
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
{/* Uptime */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span
class={`text-xs ${isPVEItem && (node?.uptime ?? 0) < 3600
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400'
}`}
@ -695,169 +556,182 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</Show>
</span>
</div>
);
</td>
case 'cpu':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}>
<div class="md:hidden w-full">
<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>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={cpuPercentValue} type="cpu" />
</div>
</div>
);
case 'memory':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}>
<div class="md:hidden w-full">
<MetricText value={memoryPercentValue} type="memory" />
</div>
</Show>
<div class="hidden md:block">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={cpuPercentValue}
type="cpu"
resourceId={metricsKey}
isRunning={online}
showMobile={false}
/>
}>
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={node!.loadAverage?.[0]}
cores={node!.cpuInfo?.cores}
model={node!.cpuInfo?.model}
resourceId={metricsKey}
/>
</Show>
<div class="hidden md:block w-full">
<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}
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>
</td>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={memoryPercentValue} type="memory" />
</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>
);
</td>
case 'disk':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<ResponsiveMetricCell
value={diskPercentValue}
type="disk"
resourceId={metricsKey}
sublabel={diskSublabel}
isRunning={online}
showMobile={isMobile()}
class="w-full"
/>
</div>
);
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<ResponsiveMetricCell
value={diskPercentValue}
type="disk"
resourceId={metricsKey}
sublabel={diskSublabel}
isRunning={online}
showMobile={isMobile()}
/>
</td>
case 'temperature':
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<Show
when={
online &&
isPVEItem &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
>
{(() => {
const value = cpuTemperatureValue as number;
const temp = node!.temperature;
const cpuMinValue =
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
const cpuMaxValue =
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
? temp.cpuMaxRecord
: null;
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
{/* Temperature */}
<Show when={hasAnyTemperatureData()}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show
when={
online &&
isPVEItem &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
>
{(() => {
const value = cpuTemperatureValue as number;
const temp = node!.temperature;
const cpuMinValue =
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
const cpuMaxValue =
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
? temp.cpuMaxRecord
: null;
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
const gpus = temp?.gpu ?? [];
const hasGPU = gpus.length > 0;
const gpus = temp?.gpu ?? [];
const hasGPU = gpus.length > 0;
if (hasMinMax || hasGPU) {
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
if (hasMinMax || hasGPU) {
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
return (
<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 (
<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>
<TemperatureGauge value={value} />
);
}
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>
})()}
</Show>
</div>
);
}
return null;
}
};
</td>
</Show>
return (
<div
class={`${rowClass()} grid items-center`}
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
<For each={visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
);
}}
</For >
</div >
</div >
</Card >
{/* Dashboard tab: VMs and CTs */}
<Show when={props.currentTab === 'dashboard'}>
<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, 'vmCount') ?? '-' : '-'}
</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, 'containerCount') ?? '-' : '-'}
</span>
</div>
</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 {
value: number;
@ -6,7 +6,6 @@ interface TemperatureGaugeProps {
max?: number | null;
critical?: number;
warning?: number;
label?: string;
class?: string;
}
@ -14,54 +13,15 @@ export const TemperatureGauge: Component<TemperatureGaugeProps> = (props) => {
const critical = props.critical ?? 80;
const warning = props.warning ?? 70;
// Calculate percentage (assuming 0-100°C range for simplicity, or slightly dynamic)
// Most CPUs idle around 30-40, max out at 100.
const percent = createMemo(() => Math.min(100, Math.max(0, props.value)));
const colorClass = createMemo(() => {
if (props.value >= critical) return 'bg-red-500 dark:bg-red-500';
if (props.value >= warning) return 'bg-yellow-500 dark:bg-yellow-500';
return 'bg-green-500 dark:bg-green-500';
});
const textColorClass = createMemo(() => {
if (props.value >= critical) return 'text-red-700 dark:text-red-400';
if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400';
return 'text-green-700 dark:text-green-400';
if (props.value >= critical) return 'text-red-600 dark:text-red-400';
if (props.value >= warning) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-600 dark:text-gray-400';
});
return (
<div class={`flex items-center gap-2 ${props.class || ''}`}>
{/* Text Value */}
<span class={`text-xs font-medium w-[36px] text-right ${textColorClass()}`}>
{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>
<span class={`text-xs whitespace-nowrap ${textColorClass()} ${props.class || ''}`}>
{Math.round(props.value)}°C
</span>
);
};

View file

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