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:
parent
a5fea152e8
commit
f32d22dc41
8 changed files with 1390 additions and 1555 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue