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 { useNavigate } from '@solidjs/router';
|
||||||
import type { VM, Container, Node } from '@/types/api';
|
import type { VM, Container, Node } from '@/types/api';
|
||||||
import { GuestRow, GUEST_COLUMNS } from './GuestRow';
|
import { GuestRow, GUEST_COLUMNS } from './GuestRow';
|
||||||
import { useGridTemplate } from '@/components/shared/responsive';
|
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { getAlertStyles } from '@/utils/alerts';
|
import { getAlertStyles } from '@/utils/alerts';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
|
|
@ -259,9 +258,8 @@ export function Dashboard(props: DashboardProps) {
|
||||||
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
|
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
|
||||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
// Use the same grid template as GuestRow for header alignment
|
// Total columns for colspan calculations
|
||||||
const { gridTemplate: headerGridTemplate } = useGridTemplate({ columns: GUEST_COLUMNS });
|
const totalColumns = GUEST_COLUMNS.length;
|
||||||
|
|
||||||
|
|
||||||
// Load all guest metadata on mount (single API call for all guests)
|
// Load all guest metadata on mount (single API call for all guests)
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|
@ -929,165 +927,148 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{/* Table View */}
|
{/* Table View */}
|
||||||
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
||||||
<ComponentErrorBoundary name="Guest Table">
|
<ComponentErrorBoundary name="Guest Table">
|
||||||
<Card padding="none" tone="glass" class="mb-4">
|
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
{/* Desktop Header */}
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<div
|
<thead>
|
||||||
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||||
style={{ 'grid-template-columns': headerGridTemplate() }}
|
{/* Name Header */}
|
||||||
>
|
<th
|
||||||
{/* Name Header */}
|
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
<div
|
onClick={() => handleSort('name')}
|
||||||
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap"
|
>
|
||||||
onClick={() => handleSort('name')}
|
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
>
|
</th>
|
||||||
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type */}
|
{/* Type */}
|
||||||
<div
|
<th
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('type')}
|
onClick={() => handleSort('type')}
|
||||||
title="Type"
|
>
|
||||||
>
|
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<span class="hidden xl:inline">Type</span>
|
</th>
|
||||||
<span class="xl:hidden">T</span>
|
|
||||||
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* VMID */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('vmid')}
|
|
||||||
title="VMID"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">VMID</span>
|
|
||||||
<span class="xl:hidden">ID</span>
|
|
||||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* Uptime */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('uptime')}
|
|
||||||
title="Uptime"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Uptime</span>
|
|
||||||
<span class="xl:hidden">Up</span>
|
|
||||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* CPU */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('cpu')}
|
|
||||||
>
|
|
||||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
{/* Memory */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('memory')}
|
|
||||||
title="Memory"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Memory</span>
|
|
||||||
<span class="xl:hidden">Mem</span>
|
|
||||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* Disk */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('disk')}
|
|
||||||
>
|
|
||||||
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
{/* Disk Read */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('diskRead')}
|
|
||||||
title="Disk Read"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Disk Read</span>
|
|
||||||
<span class="xl:hidden">D Rd</span>
|
|
||||||
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* Disk Write */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('diskWrite')}
|
|
||||||
title="Disk Write"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Disk Write</span>
|
|
||||||
<span class="xl:hidden">D Wr</span>
|
|
||||||
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* Net In */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('networkIn')}
|
|
||||||
title="Net In"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Net In</span>
|
|
||||||
<span class="xl:hidden">N In</span>
|
|
||||||
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
{/* Net Out */}
|
|
||||||
<div
|
|
||||||
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
|
|
||||||
onClick={() => handleSort('networkOut')}
|
|
||||||
title="Net Out"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Net Out</span>
|
|
||||||
<span class="xl:hidden">N Out</span>
|
|
||||||
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Guest List */}
|
{/* VMID */}
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
|
<th
|
||||||
<For
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
onClick={() => handleSort('vmid')}
|
||||||
const nodeA = nodeByInstance()[instanceIdA];
|
>
|
||||||
const nodeB = nodeByInstance()[instanceIdB];
|
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
|
</th>
|
||||||
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
|
|
||||||
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
|
{/* Uptime */}
|
||||||
})}
|
<th
|
||||||
fallback={<></>}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
>
|
onClick={() => handleSort('uptime')}
|
||||||
{([instanceId, guests]) => {
|
>
|
||||||
const node = nodeByInstance()[instanceId];
|
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
return (
|
</th>
|
||||||
<>
|
|
||||||
<Show when={node && groupingMode() === 'grouped'}>
|
{/* CPU */}
|
||||||
<NodeGroupHeader node={node!} />
|
<th
|
||||||
</Show>
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
<For each={guests} fallback={<></>}>
|
onClick={() => handleSort('cpu')}
|
||||||
{(guest) => {
|
>
|
||||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
const metadata =
|
</th>
|
||||||
guestMetadata()[guestId] ||
|
|
||||||
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
{/* Memory */}
|
||||||
const parentNode = node ?? resolveParentNode(guest);
|
<th
|
||||||
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
return (
|
onClick={() => handleSort('memory')}
|
||||||
<ComponentErrorBoundary name="GuestRow">
|
>
|
||||||
<GuestRow
|
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
guest={guest}
|
</th>
|
||||||
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
|
||||||
customUrl={metadata?.customUrl}
|
{/* Disk */}
|
||||||
onTagClick={handleTagClick}
|
<th
|
||||||
activeSearch={search()}
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
parentNodeOnline={parentNodeOnline}
|
onClick={() => handleSort('disk')}
|
||||||
onCustomUrlUpdate={handleCustomUrlUpdate}
|
>
|
||||||
isGroupedView={groupingMode() === 'grouped'}
|
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
/>
|
</th>
|
||||||
</ComponentErrorBoundary>
|
|
||||||
);
|
{/* Disk Read */}
|
||||||
}}
|
<th
|
||||||
</For>
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
</>
|
onClick={() => handleSort('diskRead')}
|
||||||
);
|
>
|
||||||
}}
|
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</For>
|
</th>
|
||||||
</div>
|
|
||||||
|
{/* Disk Write */}
|
||||||
|
<th
|
||||||
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('diskWrite')}
|
||||||
|
>
|
||||||
|
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</th>
|
||||||
|
|
||||||
|
{/* Net In */}
|
||||||
|
<th
|
||||||
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('networkIn')}
|
||||||
|
>
|
||||||
|
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</th>
|
||||||
|
|
||||||
|
{/* Net Out */}
|
||||||
|
<th
|
||||||
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('networkOut')}
|
||||||
|
>
|
||||||
|
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<For
|
||||||
|
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
||||||
|
const nodeA = nodeByInstance()[instanceIdA];
|
||||||
|
const nodeB = nodeByInstance()[instanceIdB];
|
||||||
|
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
|
||||||
|
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
|
||||||
|
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
|
||||||
|
})}
|
||||||
|
fallback={<></>}
|
||||||
|
>
|
||||||
|
{([instanceId, guests]) => {
|
||||||
|
const node = nodeByInstance()[instanceId];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={node && groupingMode() === 'grouped'}>
|
||||||
|
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
|
||||||
|
</Show>
|
||||||
|
<For each={guests} fallback={<></>}>
|
||||||
|
{(guest) => {
|
||||||
|
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||||
|
const metadata =
|
||||||
|
guestMetadata()[guestId] ||
|
||||||
|
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
||||||
|
const parentNode = node ?? resolveParentNode(guest);
|
||||||
|
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
||||||
|
return (
|
||||||
|
<ComponentErrorBoundary name="GuestRow">
|
||||||
|
<GuestRow
|
||||||
|
guest={guest}
|
||||||
|
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
||||||
|
customUrl={metadata?.customUrl}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
activeSearch={search()}
|
||||||
|
parentNodeOnline={parentNodeOnline}
|
||||||
|
onCustomUrlUpdate={handleCustomUrlUpdate}
|
||||||
|
isGroupedView={groupingMode() === 'grouped'}
|
||||||
|
/>
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { GuestDrawer } from './GuestDrawer';
|
import { GuestDrawer } from './GuestDrawer';
|
||||||
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
|
||||||
import type { VM, Container } from '@/types/api';
|
import type { VM, Container } from '@/types/api';
|
||||||
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
|
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
|
||||||
import { TagBadges } from './TagBadges';
|
import { TagBadges } from './TagBadges';
|
||||||
|
|
@ -13,7 +13,8 @@ import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||||
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
||||||
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
|
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
|
|
||||||
type Guest = VM | Container;
|
type Guest = VM | Container;
|
||||||
|
|
||||||
|
|
@ -113,19 +114,19 @@ interface ColumnDef {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GUEST_COLUMNS: ColumnDef[] = [
|
export const GUEST_COLUMNS: ColumnDef[] = [
|
||||||
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', maxWidth: '300px' },
|
{ id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' },
|
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' },
|
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' },
|
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
// Metric columns - fixed width to match progress bar max-width (140px + padding)
|
// Metric columns - fixed min/max width to match progress bar
|
||||||
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
|
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
|
||||||
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
|
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
||||||
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
|
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
||||||
// I/O columns - fixed width
|
// I/O columns - auto sizing
|
||||||
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
|
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
|
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
|
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
|
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface GuestRowProps {
|
interface GuestRowProps {
|
||||||
|
|
@ -156,8 +157,8 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const guestId = createMemo(() => buildGuestId(props.guest));
|
const guestId = createMemo(() => buildGuestId(props.guest));
|
||||||
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
|
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
|
||||||
|
|
||||||
// Use the responsive grid template hook for dynamic column visibility
|
// Use breakpoint hook directly for responsive behavior
|
||||||
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS });
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
// Create namespaced metrics key
|
// Create namespaced metrics key
|
||||||
const metricsKey = createMemo(() => {
|
const metricsKey = createMemo(() => {
|
||||||
|
|
@ -485,142 +486,146 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render cell content based on column type
|
// Total columns for colspan calculation
|
||||||
const renderCell = (column: ColumnDef) => {
|
const totalColumns = GUEST_COLUMNS.length;
|
||||||
switch (column.id) {
|
|
||||||
case 'name':
|
return (
|
||||||
return (
|
<>
|
||||||
<div class={`px-1 py-1 flex items-center min-w-0 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
<tr
|
||||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
class={rowClass()}
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
style={rowStyle()}
|
||||||
<StatusDot
|
onClick={toggleDrawer}
|
||||||
variant={guestStatus().variant}
|
aria-expanded={drawerOpen()}
|
||||||
title={guestStatus().label}
|
>
|
||||||
ariaLabel={guestStatus().label}
|
{/* Name */}
|
||||||
size="xs"
|
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
||||||
/>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<Show
|
<StatusDot
|
||||||
when={isEditingUrl()}
|
variant={guestStatus().variant}
|
||||||
fallback={
|
title={guestStatus().label}
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
ariaLabel={guestStatus().label}
|
||||||
<span
|
size="xs"
|
||||||
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap"
|
/>
|
||||||
style="cursor: text;"
|
<Show
|
||||||
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
when={isEditingUrl()}
|
||||||
onClick={startEditingUrl}
|
fallback={
|
||||||
data-guest-name-editable
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none whitespace-nowrap"
|
||||||
|
style="cursor: text;"
|
||||||
|
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
|
onClick={startEditingUrl}
|
||||||
|
data-guest-name-editable
|
||||||
|
>
|
||||||
|
{props.guest.name}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl()}>
|
||||||
|
<a
|
||||||
|
href={customUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
||||||
|
title="Open in new tab"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
{props.guest.name}
|
<path
|
||||||
</span>
|
stroke-linecap="round"
|
||||||
<Show when={customUrl()}>
|
stroke-linejoin="round"
|
||||||
<a
|
stroke-width="2"
|
||||||
href={customUrl()}
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
target="_blank"
|
/>
|
||||||
rel="noopener noreferrer"
|
</svg>
|
||||||
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
</a>
|
||||||
title="Open in new tab"
|
</Show>
|
||||||
onClick={(event) => event.stopPropagation()}
|
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
|
||||||
>
|
</div>
|
||||||
<svg
|
}
|
||||||
class="w-3.5 h-3.5"
|
>
|
||||||
fill="none"
|
<div class="flex items-center gap-1 min-w-0" data-url-editor>
|
||||||
stroke="currentColor"
|
<input
|
||||||
viewBox="0 0 24 24"
|
ref={urlInputRef}
|
||||||
>
|
type="text"
|
||||||
<path
|
value={editingUrlValue()}
|
||||||
stroke-linecap="round"
|
data-guest-id={guestId()}
|
||||||
stroke-linejoin="round"
|
onInput={(e) => {
|
||||||
stroke-width="2"
|
editingValues.set(guestId(), e.currentTarget.value);
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
setEditingValuesVersion(v => v + 1);
|
||||||
/>
|
}}
|
||||||
</svg>
|
onKeyDown={(e) => {
|
||||||
</a>
|
if (e.key === 'Enter') {
|
||||||
</Show>
|
e.preventDefault();
|
||||||
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
|
saveUrl();
|
||||||
</div>
|
} else if (e.key === 'Escape') {
|
||||||
}
|
e.preventDefault();
|
||||||
>
|
cancelEditingUrl();
|
||||||
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
}
|
||||||
<input
|
}}
|
||||||
ref={urlInputRef}
|
onClick={(e) => e.stopPropagation()}
|
||||||
type="text"
|
placeholder="https://192.168.1.100:8006"
|
||||||
value={editingUrlValue()}
|
class="min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
data-guest-id={guestId()}
|
|
||||||
onInput={(e) => {
|
|
||||||
editingValues.set(guestId(), e.currentTarget.value);
|
|
||||||
setEditingValuesVersion(v => v + 1);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveUrl();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
cancelEditingUrl();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
placeholder="https://192.168.1.100:8006"
|
|
||||||
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
saveUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
title="Save (or press Enter)"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
|
||||||
title="Delete URL"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!isEditingUrl()}>
|
|
||||||
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
|
||||||
<TagBadges
|
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
|
||||||
maxVisible={3}
|
|
||||||
onTagClick={props.onTagClick}
|
|
||||||
activeSearch={props.activeSearch}
|
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
saveUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
title="Save (or press Enter)"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete URL"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={lockLabel()}>
|
|
||||||
<span
|
|
||||||
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
|
||||||
title={`Guest is locked (${lockLabel()})`}
|
|
||||||
>
|
|
||||||
Lock: {lockLabel()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'type':
|
<Show when={!isEditingUrl()}>
|
||||||
return (
|
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
||||||
<div class="px-0.5 py-1 flex justify-center items-center">
|
<TagBadges
|
||||||
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
|
maxVisible={3}
|
||||||
|
onTagClick={props.onTagClick}
|
||||||
|
activeSearch={props.activeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={lockLabel()}>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap"
|
||||||
|
title={`Guest is locked (${lockLabel()})`}
|
||||||
|
>
|
||||||
|
Lock: {lockLabel()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
<span
|
<span
|
||||||
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded ${props.guest.type === 'qemu'
|
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -629,31 +634,47 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
{isVM(props.guest) ? 'VM' : 'LXC'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'vmid':
|
{/* VMID */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400">
|
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
{props.guest.vmid}
|
{props.guest.vmid}
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'uptime':
|
{/* Uptime */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center">
|
<div class="flex justify-center">
|
||||||
<div class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
<span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
<Show when={isRunning()} fallback="-">
|
<Show when={isRunning()} fallback="-">
|
||||||
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
|
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
|
||||||
{formatUptime(props.guest.uptime, true)}
|
{formatUptime(props.guest.uptime, true)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'cpu':
|
{/* CPU */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={cpuPercent()}
|
||||||
|
type="cpu"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
sublabel={
|
||||||
|
props.guest.cpus
|
||||||
|
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isRunning={isRunning()}
|
||||||
|
showMobile={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
<ResponsiveMetricCell
|
<ResponsiveMetricCell
|
||||||
value={cpuPercent()}
|
value={cpuPercent()}
|
||||||
type="cpu"
|
type="cpu"
|
||||||
|
|
@ -664,134 +685,111 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
isRunning={isRunning()}
|
isRunning={isRunning()}
|
||||||
showMobile={isMobile()}
|
showMobile={false}
|
||||||
class="w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'memory':
|
{/* Memory */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
<div title={memoryTooltip() ?? undefined}>
|
||||||
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
|
<Show when={isMobile()}>
|
||||||
<Show when={isMobile()}>
|
<div class="md:hidden flex justify-center">
|
||||||
<div class="md:hidden">
|
<ResponsiveMetricCell
|
||||||
<ResponsiveMetricCell
|
value={memPercent()}
|
||||||
value={memPercent()}
|
type="memory"
|
||||||
type="memory"
|
resourceId={metricsKey()}
|
||||||
resourceId={metricsKey()}
|
sublabel={memoryUsageLabel()}
|
||||||
sublabel={memoryUsageLabel()}
|
isRunning={isRunning()}
|
||||||
isRunning={isRunning()}
|
showMobile={true}
|
||||||
showMobile={true}
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<StackedMemoryBar
|
|
||||||
used={props.guest.memory?.used || 0}
|
|
||||||
total={props.guest.memory?.total || 0}
|
|
||||||
balloon={props.guest.memory?.balloon || 0}
|
|
||||||
swapUsed={props.guest.memory?.swapUsed || 0}
|
|
||||||
swapTotal={props.guest.memory?.swapTotal || 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'disk':
|
|
||||||
return (
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
|
||||||
<Show
|
|
||||||
when={hasDiskUsage()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
|
||||||
-
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Mobile: simple percentage text */}
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden text-xs text-center text-gray-600 dark:text-gray-400">
|
|
||||||
{formatPercent(diskPercent())}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
{/* Desktop: stacked disk bar for multiple disks */}
|
|
||||||
<div class={isMobile() ? 'hidden md:block w-full' : 'w-full'}>
|
|
||||||
<StackedDiskBar
|
|
||||||
disks={props.guest.disks}
|
|
||||||
aggregateDisk={props.guest.disk}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<StackedMemoryBar
|
||||||
|
used={props.guest.memory?.used || 0}
|
||||||
|
total={props.guest.memory?.total || 0}
|
||||||
|
balloon={props.guest.memory?.balloon || 0}
|
||||||
|
swapUsed={props.guest.memory?.swapUsed || 0}
|
||||||
|
swapTotal={props.guest.memory?.swapTotal || 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'diskRead':
|
{/* Disk */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class="py-1 flex justify-center items-center min-h-[24px]">
|
<Show
|
||||||
|
when={hasDiskUsage()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{formatPercent(diskPercent())}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class={isMobile() ? 'hidden md:block' : ''}>
|
||||||
|
<StackedDiskBar
|
||||||
|
disks={props.guest.disks}
|
||||||
|
aggregateDisk={props.guest.disk}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Disk Read */}
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'diskWrite':
|
{/* Disk Write */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="py-1 flex justify-center items-center min-h-[24px]">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'netIn':
|
{/* Net In */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="py-1 flex justify-center items-center min-h-[24px]">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'netOut':
|
{/* Net Out */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="py-1 flex justify-center items-center min-h-[24px]">
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
</tr>
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
class={`${rowClass()} grid items-center`}
|
|
||||||
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
|
||||||
onClick={toggleDrawer}
|
|
||||||
aria-expanded={drawerOpen()}
|
|
||||||
>
|
|
||||||
<For each={visibleColumns()}>
|
|
||||||
{(column) => renderCell(column)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={drawerOpen() && canShowDrawer()}>
|
<Show when={drawerOpen() && canShowDrawer()}>
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
<tr class="bg-gray-50 dark:bg-gray-900/50">
|
||||||
<div class="p-4">
|
<td colspan={totalColumns} class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<GuestDrawer
|
<GuestDrawer
|
||||||
guest={props.guest}
|
guest={props.guest}
|
||||||
metricsKey={metricsKey()}
|
metricsKey={metricsKey()}
|
||||||
onClose={() => setCurrentlyExpandedGuestId(null)}
|
onClose={() => setCurrentlyExpandedGuestId(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,12 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
||||||
<ScrollableTable minWidth="300px" persistKey="docker-host-summary">
|
<ScrollableTable persistKey="docker-host-summary">
|
||||||
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||||
<th
|
<th
|
||||||
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[35%] md:w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -150,47 +150,47 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
>
|
>
|
||||||
Host {renderSortIndicator('name')}
|
Host {renderSortIndicator('name')}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] md:w-[6%] whitespace-nowrap">
|
<th class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('cpu')}
|
onClick={() => handleSort('cpu')}
|
||||||
>
|
>
|
||||||
CPU {renderSortIndicator('cpu')}
|
CPU {renderSortIndicator('cpu')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('memory')}
|
onClick={() => handleSort('memory')}
|
||||||
>
|
>
|
||||||
Memory {renderSortIndicator('memory')}
|
Memory {renderSortIndicator('memory')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('disk')}
|
onClick={() => handleSort('disk')}
|
||||||
>
|
>
|
||||||
Disk {renderSortIndicator('disk')}
|
Disk {renderSortIndicator('disk')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('running')}
|
onClick={() => handleSort('running')}
|
||||||
>
|
>
|
||||||
Containers {renderSortIndicator('running')}
|
Containers {renderSortIndicator('running')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('uptime')}
|
onClick={() => handleSort('uptime')}
|
||||||
>
|
>
|
||||||
Uptime {renderSortIndicator('uptime')}
|
Uptime {renderSortIndicator('uptime')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('lastSeen')}
|
onClick={() => handleSort('lastSeen')}
|
||||||
>
|
>
|
||||||
Last Update {renderSortIndicator('lastSeen')}
|
Last Update {renderSortIndicator('lastSeen')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('agent')}
|
onClick={() => handleSort('agent')}
|
||||||
>
|
>
|
||||||
Agent {renderSortIndicator('agent')}
|
Agent {renderSortIndicator('agent')}
|
||||||
|
|
@ -278,14 +278,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
|
<div class="flex justify-center items-center h-full w-full whitespace-nowrap">
|
||||||
{renderDockerStatusBadge(summary.host.status)}
|
{renderDockerStatusBadge(summary.host.status)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<Show when={isMobile()}>
|
<Show when={isMobile()}>
|
||||||
<div class="md:hidden">
|
<div class="md:hidden flex justify-center">
|
||||||
<MetricText value={summary.cpuPercent} type="cpu" />
|
<MetricText value={summary.cpuPercent} type="cpu" />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -298,7 +298,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<ResponsiveMetricCell
|
<ResponsiveMetricCell
|
||||||
value={summary.memoryPercent}
|
value={summary.memoryPercent}
|
||||||
type="memory"
|
type="memory"
|
||||||
|
|
@ -308,7 +308,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
showMobile={isMobile()}
|
showMobile={isMobile()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<ResponsiveMetricCell
|
<ResponsiveMetricCell
|
||||||
value={summary.diskPercent}
|
value={summary.diskPercent}
|
||||||
type="disk"
|
type="disk"
|
||||||
|
|
@ -318,8 +318,8 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
showMobile={isMobile()}
|
showMobile={isMobile()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full whitespace-nowrap w-full px-2">
|
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
|
||||||
<Show
|
<Show
|
||||||
when={summary.totalCount > 0}
|
when={summary.totalCount > 0}
|
||||||
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
||||||
|
|
@ -333,14 +333,14 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-2 py-1 align-middle whitespace-nowrap sm:table-cell">
|
<td class="px-2 py-1 align-middle whitespace-nowrap">
|
||||||
<div class="flex justify-center items-center h-full">
|
<div class="flex justify-center items-center h-full">
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
{uptimeLabel}
|
{uptimeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-2 py-1 align-middle sm:table-cell">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full">
|
<div class="flex justify-center items-center h-full">
|
||||||
<Show
|
<Show
|
||||||
when={summary.lastSeenRelative}
|
when={summary.lastSeenRelative}
|
||||||
|
|
@ -357,7 +357,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-2 py-1 align-middle sm:table-cell">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
|
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
|
||||||
<Show
|
<Show
|
||||||
when={summary.host.agentVersion}
|
when={summary.host.agentVersion}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ import {
|
||||||
getDockerServiceStatusIndicator,
|
getDockerServiceStatusIndicator,
|
||||||
} from '@/utils/status';
|
} from '@/utils/status';
|
||||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||||
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
|
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
||||||
import type { ColumnConfig } from '@/types/responsive';
|
import type { ColumnConfig } from '@/types/responsive';
|
||||||
|
|
||||||
|
|
@ -123,16 +124,16 @@ interface DockerColumnDef extends ColumnConfig {
|
||||||
// - supplementary: Visible on large screens and up (lg: 1024px+)
|
// - supplementary: Visible on large screens and up (lg: 1024px+)
|
||||||
// - detailed: Visible on extra large screens and up (xl: 1280px+)
|
// - detailed: Visible on extra large screens and up (xl: 1280px+)
|
||||||
export const DOCKER_COLUMNS: DockerColumnDef[] = [
|
export const DOCKER_COLUMNS: DockerColumnDef[] = [
|
||||||
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: '100px', flex: 1, sortKey: 'resource' },
|
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource' },
|
||||||
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '50px', maxWidth: '80px', sortKey: 'type' },
|
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type' },
|
||||||
{ id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'essential', minWidth: '80px', maxWidth: '250px', sortKey: 'image' },
|
{ id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'image' },
|
||||||
{ id: 'status', label: 'Status', priority: 'essential', minWidth: '80px', maxWidth: '130px', sortKey: 'status' },
|
{ id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status' },
|
||||||
// Metric columns - fixed width to match progress bar max-width (140px + padding)
|
// Metric columns - need fixed width to match progress bar max-width (140px + padding)
|
||||||
// Note: Disk column removed - Docker API rarely provides this data
|
// Note: Disk column removed - Docker API rarely provides this data
|
||||||
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'cpu' },
|
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu' },
|
||||||
{ id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'memory' },
|
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory' },
|
||||||
{ id: 'tasks', label: 'Tasks', shortLabel: 'Tasks', priority: 'essential', minWidth: '60px', maxWidth: '80px', sortKey: 'tasks' },
|
{ id: 'tasks', label: 'Tasks', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'tasks' },
|
||||||
{ id: 'updated', label: 'Uptime', shortLabel: 'Uptime', priority: 'essential', minWidth: '70px', maxWidth: '90px', sortKey: 'updated' },
|
{ id: 'updated', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'updated' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Global state for currently expanded drawer (only one drawer open at a time)
|
// Global state for currently expanded drawer (only one drawer open at a time)
|
||||||
|
|
@ -732,39 +733,38 @@ const UNGROUPED_RESOURCE_INDENT = 'pl-4 sm:pl-5 lg:pl-6';
|
||||||
|
|
||||||
const DockerHostGroupHeader: Component<{
|
const DockerHostGroupHeader: Component<{
|
||||||
host: DockerHost;
|
host: DockerHost;
|
||||||
gridTemplate: Accessor<string>;
|
columnCount: number;
|
||||||
visibleColumns: Accessor<ColumnConfig[]>;
|
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const displayName = getHostDisplayName(props.host);
|
const displayName = getHostDisplayName(props.host);
|
||||||
const hostStatus = () => getDockerHostStatusIndicator(props.host);
|
const hostStatus = () => getDockerHostStatusIndicator(props.host);
|
||||||
const isOnline = () => hostStatus().variant === 'success';
|
const isOnline = () => hostStatus().variant === 'success';
|
||||||
return (
|
return (
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/40 py-0.5 pr-2 pl-4">
|
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||||||
<div
|
<td colspan={props.columnCount} class="py-0.5 pr-2 pl-4">
|
||||||
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
|
<div
|
||||||
title={hostStatus().label}
|
class={`flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100 ${isOnline() ? '' : 'opacity-60'}`}
|
||||||
>
|
|
||||||
<StatusDot
|
|
||||||
variant={hostStatus().variant}
|
|
||||||
title={hostStatus().label}
|
title={hostStatus().label}
|
||||||
ariaLabel={hostStatus().label}
|
>
|
||||||
size="xs"
|
<StatusDot
|
||||||
/>
|
variant={hostStatus().variant}
|
||||||
<span>{displayName}</span>
|
title={hostStatus().label}
|
||||||
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
|
ariaLabel={hostStatus().label}
|
||||||
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
|
size="xs"
|
||||||
({props.host.hostname})
|
/>
|
||||||
</span>
|
<span>{displayName}</span>
|
||||||
</Show>
|
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
|
||||||
</div>
|
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
|
||||||
</div>
|
({props.host.hostname})
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DockerContainerRow: Component<{
|
const DockerContainerRow: Component<{
|
||||||
row: Extract<DockerRow, { kind: 'container' }>;
|
row: Extract<DockerRow, { kind: 'container' }>;
|
||||||
visibleColumns: Accessor<ColumnConfig[]>;
|
|
||||||
gridTemplate: Accessor<string>;
|
|
||||||
isMobile: Accessor<boolean>;
|
isMobile: Accessor<boolean>;
|
||||||
customUrl?: string;
|
customUrl?: string;
|
||||||
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
||||||
|
|
@ -1172,14 +1172,14 @@ const DockerContainerRow: Component<{
|
||||||
);
|
);
|
||||||
case 'image':
|
case 'image':
|
||||||
return (
|
return (
|
||||||
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate overflow-hidden">
|
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
<span title={container.image}>{container.image || '—'}</span>
|
{container.image || '—'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div class="px-2 py-0.5 text-xs overflow-hidden">
|
<div class="px-2 py-0.5 text-xs whitespace-nowrap">
|
||||||
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap block truncate ${statusBadgeClass()}`}>{statusLabel()}</span>
|
<span class={`rounded px-2 py-0.5 text-[10px] font-medium ${statusBadgeClass()}`}>{statusLabel()}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'cpu':
|
case 'cpu':
|
||||||
|
|
@ -1270,24 +1270,32 @@ const DockerContainerRow: Component<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<tr
|
||||||
class={`grid items-center transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
|
class={`transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
|
||||||
style={{ 'grid-template-columns': props.gridTemplate() }}
|
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={expanded()}
|
aria-expanded={expanded()}
|
||||||
>
|
>
|
||||||
<For each={props.visibleColumns()}>
|
<For each={DOCKER_COLUMNS}>
|
||||||
{(column) => renderCell(column)}
|
{(column) => (
|
||||||
|
<td
|
||||||
|
class="py-0.5 align-middle whitespace-nowrap"
|
||||||
|
style={{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined }}
|
||||||
|
>
|
||||||
|
{renderCell(column)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</tr>
|
||||||
|
|
||||||
<Show when={expanded() && hasDrawerContent()}>
|
<Show when={expanded() && hasDrawerContent()}>
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
|
<tr>
|
||||||
<div class="flex flex-wrap justify-start gap-3">
|
<td colspan={DOCKER_COLUMNS.length} class="p-0">
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3">
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
<div class="flex flex-wrap justify-start gap-3">
|
||||||
Summary
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
|
Summary
|
||||||
|
</div>
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
|
||||||
|
|
@ -1614,9 +1622,11 @@ const DockerContainerRow: Component<{
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -1624,8 +1634,6 @@ const DockerContainerRow: Component<{
|
||||||
|
|
||||||
const DockerServiceRow: Component<{
|
const DockerServiceRow: Component<{
|
||||||
row: Extract<DockerRow, { kind: 'service' }>;
|
row: Extract<DockerRow, { kind: 'service' }>;
|
||||||
visibleColumns: Accessor<ColumnConfig[]>;
|
|
||||||
gridTemplate: Accessor<string>;
|
|
||||||
isMobile: Accessor<boolean>;
|
isMobile: Accessor<boolean>;
|
||||||
customUrl?: string;
|
customUrl?: string;
|
||||||
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
|
||||||
|
|
@ -1923,8 +1931,8 @@ const DockerServiceRow: Component<{
|
||||||
);
|
);
|
||||||
case 'image':
|
case 'image':
|
||||||
return (
|
return (
|
||||||
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate">
|
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
<span title={service.image}>{service.image || '—'}</span>
|
{service.image || '—'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'status':
|
case 'status':
|
||||||
|
|
@ -1967,29 +1975,37 @@ const DockerServiceRow: Component<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<tr
|
||||||
class={`grid items-center transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`}
|
class={`transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isHealthy() ? 'opacity-60' : ''}`}
|
||||||
style={{ 'grid-template-columns': props.gridTemplate() }}
|
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={expanded()}
|
aria-expanded={expanded()}
|
||||||
>
|
>
|
||||||
<For each={props.visibleColumns()}>
|
<For each={DOCKER_COLUMNS}>
|
||||||
{(column) => renderCell(column)}
|
{(column) => (
|
||||||
|
<td
|
||||||
|
class="py-0.5 align-middle whitespace-nowrap"
|
||||||
|
style={{ "min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined }}
|
||||||
|
>
|
||||||
|
{renderCell(column)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</tr>
|
||||||
|
|
||||||
<Show when={expanded() && hasTasks()}>
|
<Show when={expanded() && hasTasks()}>
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
|
<tr>
|
||||||
<div class="flex flex-wrap justify-start gap-3">
|
<td colspan={DOCKER_COLUMNS.length} class="p-0">
|
||||||
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="bg-gray-50 dark:bg-gray-900/60 px-4 py-3">
|
||||||
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
<div class="flex flex-wrap justify-start gap-3">
|
||||||
<span>Tasks</span>
|
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
|
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
|
<span>Tasks</span>
|
||||||
</span>
|
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
|
||||||
<div class="mt-2 overflow-x-auto">
|
</span>
|
||||||
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-800/60 text-xs">
|
</div>
|
||||||
|
<div class="mt-2 overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-800/60 text-xs">
|
||||||
<thead class="bg-gray-100 dark:bg-gray-900/40 text-[10px] uppercase tracking-wide text-gray-600 dark:text-gray-200">
|
<thead class="bg-gray-100 dark:bg-gray-900/40 text-[10px] uppercase tracking-wide text-gray-600 dark:text-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="py-1 pr-2 text-left font-medium">Task</th>
|
<th class="py-1 pr-2 text-left font-medium">Task</th>
|
||||||
|
|
@ -2095,11 +2111,13 @@ const DockerServiceRow: Component<{
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -2114,8 +2132,8 @@ const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
// Use the responsive grid template hook for dynamic column visibility
|
// Use the breakpoint hook for responsive behavior
|
||||||
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS });
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
// Caches for stable object references to prevent re-animations
|
// Caches for stable object references to prevent re-animations
|
||||||
const rowCache = new Map<string, DockerRow>();
|
const rowCache = new Map<string, DockerRow>();
|
||||||
|
|
@ -2442,8 +2460,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
return row.kind === 'container' ? (
|
return row.kind === 'container' ? (
|
||||||
<DockerContainerRow
|
<DockerContainerRow
|
||||||
row={row}
|
row={row}
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
gridTemplate={gridTemplate}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
customUrl={metadata?.customUrl}
|
customUrl={metadata?.customUrl}
|
||||||
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
||||||
|
|
@ -2453,8 +2469,6 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
) : (
|
) : (
|
||||||
<DockerServiceRow
|
<DockerServiceRow
|
||||||
row={row}
|
row={row}
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
gridTemplate={gridTemplate}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
customUrl={metadata?.customUrl}
|
customUrl={metadata?.customUrl}
|
||||||
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
||||||
|
|
@ -2485,80 +2499,78 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
|
||||||
>
|
>
|
||||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
{/* Header Row */}
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<div
|
<thead>
|
||||||
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
|
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20">
|
||||||
style={{ 'grid-template-columns': gridTemplate() }}
|
<For each={DOCKER_COLUMNS}>
|
||||||
>
|
{(column) => {
|
||||||
<For each={visibleColumns()}>
|
const col = column as DockerColumnDef;
|
||||||
{(column) => {
|
const colSortKey = col.sortKey as SortKey | undefined;
|
||||||
const col = column as DockerColumnDef;
|
const isResource = col.id === 'resource';
|
||||||
const colSortKey = col.sortKey as SortKey | undefined;
|
return (
|
||||||
const isResource = col.id === 'resource';
|
<th
|
||||||
return (
|
class={`${isResource ? 'pl-4 sm:pl-5 lg:pl-6 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 text-left font-medium whitespace-nowrap`}
|
||||||
<div
|
style={{ "min-width": col.id === 'cpu' || col.id === 'memory' ? '140px' : undefined }}
|
||||||
class={`${isResource ? 'pl-4 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap`}
|
onClick={() => colSortKey && handleSort(colSortKey)}
|
||||||
onClick={() => colSortKey && handleSort(colSortKey)}
|
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
|
tabIndex={0}
|
||||||
tabIndex={0}
|
role="button"
|
||||||
role="button"
|
aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
||||||
aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
|
||||||
aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'}
|
>
|
||||||
>
|
<Show when={isResource}>
|
||||||
<Show when={isResource}>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<span>{col.label}</span>
|
||||||
<span>{col.label}</span>
|
{colSortKey && renderSortIndicator(colSortKey)}
|
||||||
{colSortKey && renderSortIndicator(colSortKey)}
|
<Show when={sortKey() === 'host'}>
|
||||||
<Show when={sortKey() === 'host'}>
|
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span>
|
||||||
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">Grouped by host</span>
|
</Show>
|
||||||
|
<Show when={sortKey() !== 'host'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto rounded bg-gray-200 px-2 py-0.5 text-[10px] font-medium text-gray-700 transition hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetHostGrouping();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Group by host
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={sortKey() !== 'host'}>
|
<Show when={!isResource}>
|
||||||
<button
|
<div class="flex items-center gap-1">
|
||||||
type="button"
|
<span>{col.label}</span>
|
||||||
class="ml-auto rounded bg-gray-200 px-2 py-0.5 text-[10px] font-medium text-gray-700 transition hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
{colSortKey && renderSortIndicator(colSortKey)}
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.stopPropagation();
|
|
||||||
resetHostGrouping();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Group by host
|
|
||||||
</button>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</th>
|
||||||
</Show>
|
);
|
||||||
<Show when={!isResource}>
|
}}
|
||||||
<div class="flex items-center gap-1 overflow-hidden">
|
|
||||||
<span class="hidden xl:inline truncate">{col.label}</span>
|
|
||||||
<span class="xl:hidden truncate">{col.shortLabel || col.label}</span>
|
|
||||||
{colSortKey && renderSortIndicator(colSortKey)}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
|
|
||||||
<Show
|
|
||||||
when={isGroupedView()}
|
|
||||||
fallback={
|
|
||||||
<For each={sortedRows()}>
|
|
||||||
{(row) => renderRow(row, false)}
|
|
||||||
</For>
|
</For>
|
||||||
}
|
</tr>
|
||||||
>
|
</thead>
|
||||||
<For each={orderedGroups()}>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{(group) => (
|
<Show
|
||||||
<>
|
when={isGroupedView()}
|
||||||
<DockerHostGroupHeader host={group.host} gridTemplate={gridTemplate} visibleColumns={visibleColumns} />
|
fallback={
|
||||||
<For each={group.rows}>{(row) => renderRow(row, true)}</For>
|
<For each={sortedRows()}>
|
||||||
</>
|
{(row) => renderRow(row, false)}
|
||||||
)}
|
</For>
|
||||||
</For>
|
}
|
||||||
</Show>
|
>
|
||||||
</div>
|
<For each={orderedGroups()}>
|
||||||
|
{(group) => (
|
||||||
|
<>
|
||||||
|
<DockerHostGroupHeader host={group.host} columnCount={DOCKER_COLUMNS.length} />
|
||||||
|
<For each={group.rows}>{(row) => renderRow(row, true)}</For>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,16 @@ import { HostsFilter } from './HostsFilter';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { getHostStatusIndicator } from '@/utils/status';
|
import { getHostStatusIndicator } from '@/utils/status';
|
||||||
import { MetricText, useGridTemplate } from '@/components/shared/responsive';
|
import { MetricText } from '@/components/shared/responsive';
|
||||||
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
||||||
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
||||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||||
import type { ColumnConfig } from '@/types/responsive';
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
import { STANDARD_COLUMNS } from '@/types/responsive';
|
|
||||||
|
|
||||||
// Global drawer state to persist across re-renders
|
// Global drawer state to persist across re-renders
|
||||||
const drawerState = new Map<string, boolean>();
|
const drawerState = new Map<string, boolean>();
|
||||||
|
|
||||||
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime';
|
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime';
|
||||||
|
|
||||||
const HOST_COLUMNS: ColumnConfig[] = [
|
|
||||||
{ ...STANDARD_COLUMNS.name, label: 'Host', minWidth: '200px', sortKey: 'name' },
|
|
||||||
{ id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' },
|
|
||||||
{ ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' },
|
|
||||||
{ ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' },
|
|
||||||
{ id: 'temperature', label: 'Temp', priority: 'secondary', minWidth: '80px', maxWidth: '100px' },
|
|
||||||
{ id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' },
|
|
||||||
{ ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface HostsOverviewProps {
|
interface HostsOverviewProps {
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
|
|
@ -45,18 +34,21 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
|
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
|
||||||
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
||||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||||
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: SortKey) => {
|
||||||
if (sortKey() === key) {
|
if (sortKey() === key) {
|
||||||
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
|
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
|
||||||
} else {
|
} else {
|
||||||
setSortKey(key as SortKey);
|
setSortKey(key);
|
||||||
setSortDirection('asc');
|
setSortDirection('asc');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use responsive grid template
|
const renderSortIndicator = (key: SortKey) => {
|
||||||
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS });
|
if (sortKey() !== key) return null;
|
||||||
|
return sortDirection() === 'asc' ? '▲' : '▼';
|
||||||
|
};
|
||||||
|
|
||||||
// Keyboard listener to auto-focus search
|
// Keyboard listener to auto-focus search
|
||||||
let searchInputRef: HTMLInputElement | undefined;
|
let searchInputRef: HTMLInputElement | undefined;
|
||||||
|
|
@ -116,6 +108,12 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
case 'memory':
|
case 'memory':
|
||||||
comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0);
|
comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0);
|
||||||
break;
|
break;
|
||||||
|
case 'disk': {
|
||||||
|
const aDisk = a.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0;
|
||||||
|
const bDisk = b.disks?.reduce((sum, d) => sum + (d.usage ?? 0), 0) ?? 0;
|
||||||
|
comparison = aDisk - bDisk;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'uptime':
|
case 'uptime':
|
||||||
comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0);
|
comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0);
|
||||||
break;
|
break;
|
||||||
|
|
@ -154,177 +152,46 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host));
|
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host));
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCell = (column: ColumnConfig, host: Host) => {
|
const getTemperatureValue = (host: Host) => {
|
||||||
const cpuPercent = () => host.cpuUsage ?? 0;
|
if (!host.sensors?.temperatureCelsius) return null;
|
||||||
const memPercent = () => host.memory?.usage ?? 0;
|
const temps = host.sensors.temperatureCelsius;
|
||||||
|
|
||||||
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
// Try to find a "package" or "composite" temperature first
|
||||||
|
const packageKey = Object.keys(temps).find(k =>
|
||||||
|
k.toLowerCase().includes('package') ||
|
||||||
|
k.toLowerCase().includes('composite') ||
|
||||||
|
k.toLowerCase().includes('tctl')
|
||||||
|
);
|
||||||
|
|
||||||
switch (column.id) {
|
if (packageKey) return temps[packageKey];
|
||||||
case 'name':
|
|
||||||
return (
|
|
||||||
<div class="pl-4 pr-2 py-1 overflow-hidden">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<StatusDot
|
|
||||||
variant={hostStatus().variant}
|
|
||||||
title={hostStatus().label}
|
|
||||||
ariaLabel={hostStatus().label}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
|
||||||
{host.displayName || host.hostname || host.id}
|
|
||||||
</p>
|
|
||||||
<Show when={host.displayName && host.displayName !== host.hostname}>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
|
||||||
{host.hostname}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.lastSeen}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
|
||||||
Updated {formatRelativeTime(host.lastSeen!)}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'platform':
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 overflow-hidden">
|
|
||||||
<div class="text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
<p class="font-medium capitalize">{host.platform || '—'}</p>
|
|
||||||
<Show when={host.osName}>
|
|
||||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{host.osName} {host.osVersion}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'cpu':
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 overflow-hidden">
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden">
|
|
||||||
<MetricText value={cpuPercent()} type="cpu" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<EnhancedCPUBar
|
|
||||||
usage={cpuPercent()}
|
|
||||||
loadAverage={host.loadAverage?.[0]}
|
|
||||||
cores={host.cpuCount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'memory':
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 overflow-hidden">
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden">
|
|
||||||
<MetricText value={memPercent()} type="memory" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<StackedMemoryBar
|
|
||||||
used={host.memory?.used || 0}
|
|
||||||
total={host.memory?.total || 0}
|
|
||||||
balloon={host.memory?.balloon || 0}
|
|
||||||
swapUsed={host.memory?.swapUsed || 0}
|
|
||||||
swapTotal={host.memory?.swapTotal || 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'temperature':
|
|
||||||
const tempValue = (() => {
|
|
||||||
if (!host.sensors?.temperatureCelsius) return null;
|
|
||||||
const temps = host.sensors.temperatureCelsius;
|
|
||||||
|
|
||||||
// Try to find a "package" or "composite" temperature first
|
// Fallback: average of all core temps
|
||||||
const packageKey = Object.keys(temps).find(k =>
|
const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
|
||||||
k.toLowerCase().includes('package') ||
|
if (coreKeys.length > 0) {
|
||||||
k.toLowerCase().includes('composite') ||
|
const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
|
||||||
k.toLowerCase().includes('tctl')
|
return sum / coreKeys.length;
|
||||||
);
|
|
||||||
|
|
||||||
if (packageKey) return temps[packageKey];
|
|
||||||
|
|
||||||
// Fallback: average of all core temps
|
|
||||||
const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core'));
|
|
||||||
if (coreKeys.length > 0) {
|
|
||||||
const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0);
|
|
||||||
return sum / coreKeys.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: just take the first available value if any
|
|
||||||
const values = Object.values(temps);
|
|
||||||
if (values.length > 0) return values[0];
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 overflow-hidden">
|
|
||||||
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}>
|
|
||||||
<TemperatureGauge value={tempValue!} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'disk':
|
|
||||||
const diskStats = (() => {
|
|
||||||
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
|
|
||||||
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
|
|
||||||
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
|
|
||||||
return {
|
|
||||||
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
|
|
||||||
used: totalUsed,
|
|
||||||
total: totalSize
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
const diskPercent = diskStats.percent;
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 overflow-hidden">
|
|
||||||
<Show when={isMobile()}>
|
|
||||||
<div class="md:hidden">
|
|
||||||
<MetricText value={diskPercent} type="disk" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<StackedDiskBar
|
|
||||||
disks={host.disks}
|
|
||||||
aggregateDisk={{
|
|
||||||
total: diskStats.total,
|
|
||||||
used: diskStats.used,
|
|
||||||
free: diskStats.total - diskStats.used,
|
|
||||||
usage: diskStats.percent / 100
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'uptime':
|
|
||||||
return (
|
|
||||||
<div class="px-2 py-1 text-right overflow-hidden">
|
|
||||||
<Show
|
|
||||||
when={host.uptimeSeconds}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
|
||||||
>
|
|
||||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{formatUptime(host.uptimeSeconds!)}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: just take the first available value if any
|
||||||
|
const values = Object.values(temps);
|
||||||
|
if (values.length > 0) return values[0];
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDiskStats = (host: Host) => {
|
||||||
|
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
|
||||||
|
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
|
||||||
|
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
|
||||||
|
return {
|
||||||
|
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
|
||||||
|
used: totalUsed,
|
||||||
|
total: totalSize
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Show when={isLoading()}>
|
<Show when={isLoading()}>
|
||||||
|
|
@ -397,277 +264,403 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
>
|
>
|
||||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
{/* Header */}
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<div
|
<thead>
|
||||||
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||||
style={{ 'grid-template-columns': gridTemplate() }}
|
<th
|
||||||
>
|
class={`${thClass} text-left pl-4`}
|
||||||
<For each={visibleColumns()}>
|
onClick={() => handleSort('name')}
|
||||||
{(column) => (
|
|
||||||
<div
|
|
||||||
class={`${column.id === 'name' ? 'pl-4 pr-2' : 'px-2'} py-1 flex items-center ${column.align === 'right' ? 'justify-end' : 'justify-start'} ${column.sortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
|
||||||
onClick={() => column.sortable && column.sortKey && handleSort(column.sortKey)}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
Host {renderSortIndicator('name')}
|
||||||
{column.label}
|
</th>
|
||||||
{column.sortKey === sortKey() && (
|
<th class={thClass} onClick={() => handleSort('platform')}>
|
||||||
<span class="text-gray-400">
|
Platform {renderSortIndicator('platform')}
|
||||||
{sortDirection() === 'asc' ? '▲' : '▼'}
|
</th>
|
||||||
</span>
|
<th class={thClass} onClick={() => handleSort('cpu')}>
|
||||||
)}
|
CPU {renderSortIndicator('cpu')}
|
||||||
</div>
|
</th>
|
||||||
</div>
|
<th class={thClass} onClick={() => handleSort('memory')}>
|
||||||
)}
|
Memory {renderSortIndicator('memory')}
|
||||||
</For>
|
</th>
|
||||||
</div>
|
<th class={thClass}>
|
||||||
|
Temp
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('disk')}>
|
||||||
|
Disk {renderSortIndicator('disk')}
|
||||||
|
</th>
|
||||||
|
<th class={`${thClass} text-right pr-4`} onClick={() => handleSort('uptime')}>
|
||||||
|
Uptime {renderSortIndicator('uptime')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<For each={filteredHosts()}>
|
||||||
|
{(host) => {
|
||||||
|
// Drawer state
|
||||||
|
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
||||||
|
|
||||||
{/* Rows */}
|
// Check if we have additional info to show in drawer
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
|
const hasDrawerContent = createMemo(() => {
|
||||||
<For each={filteredHosts()}>
|
return (
|
||||||
{(host) => {
|
(host.disks && host.disks.length > 0) ||
|
||||||
// Drawer state
|
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
(host.raid && host.raid.length > 0) ||
|
||||||
|
host.loadAverage ||
|
||||||
|
host.cpuCount ||
|
||||||
|
host.kernelVersion ||
|
||||||
|
host.architecture ||
|
||||||
|
host.agentVersion ||
|
||||||
|
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleDrawer = (event: MouseEvent) => {
|
||||||
|
if (!hasDrawerContent()) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('a, button, [data-prevent-toggle]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDrawerOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync drawer state
|
||||||
|
createEffect(on(() => host.id, (id) => {
|
||||||
|
const stored = drawerState.get(id);
|
||||||
|
if (stored !== undefined) {
|
||||||
|
setDrawerOpen(stored);
|
||||||
|
} else {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
drawerState.set(host.id, drawerOpen());
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowClass = () => {
|
||||||
|
const base = 'transition-all duration-200';
|
||||||
|
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
||||||
|
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
||||||
|
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
||||||
|
return `${base} ${hover} ${clickable} ${expanded}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
||||||
|
const cpuPercent = host.cpuUsage ?? 0;
|
||||||
|
const memPercent = host.memory?.usage ?? 0;
|
||||||
|
const tempValue = getTemperatureValue(host);
|
||||||
|
const diskStats = getDiskStats(host);
|
||||||
|
|
||||||
// Check if we have additional info to show in drawer
|
|
||||||
const hasDrawerContent = createMemo(() => {
|
|
||||||
return (
|
return (
|
||||||
(host.disks && host.disks.length > 0) ||
|
<>
|
||||||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
<tr
|
||||||
(host.raid && host.raid.length > 0) ||
|
class={rowClass()}
|
||||||
host.loadAverage ||
|
onClick={toggleDrawer}
|
||||||
host.cpuCount ||
|
aria-expanded={drawerOpen()}
|
||||||
host.kernelVersion ||
|
>
|
||||||
host.architecture ||
|
{/* Host Name */}
|
||||||
host.agentVersion ||
|
<td class="pl-4 pr-2 py-1 align-middle">
|
||||||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
);
|
<StatusDot
|
||||||
});
|
variant={hostStatus().variant}
|
||||||
|
title={hostStatus().label}
|
||||||
const toggleDrawer = (event: MouseEvent) => {
|
ariaLabel={hostStatus().label}
|
||||||
if (!hasDrawerContent()) return;
|
size="xs"
|
||||||
const target = event.target as HTMLElement;
|
/>
|
||||||
if (target.closest('a, button, [data-prevent-toggle]')) {
|
<div class="min-w-0">
|
||||||
return;
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
|
||||||
}
|
{host.displayName || host.hostname || host.id}
|
||||||
setDrawerOpen((prev) => !prev);
|
</p>
|
||||||
};
|
<Show when={host.displayName && host.displayName !== host.hostname}>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
// Sync drawer state
|
{host.hostname}
|
||||||
createEffect(on(() => host.id, (id) => {
|
</p>
|
||||||
const stored = drawerState.get(id);
|
|
||||||
if (stored !== undefined) {
|
|
||||||
setDrawerOpen(stored);
|
|
||||||
} else {
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
drawerState.set(host.id, drawerOpen());
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowClass = () => {
|
|
||||||
const base = 'grid items-center transition-all duration-200';
|
|
||||||
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
|
||||||
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
|
||||||
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
|
||||||
return `${base} ${hover} ${clickable} ${expanded}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
class={rowClass()}
|
|
||||||
style={{ 'grid-template-columns': gridTemplate() }}
|
|
||||||
onClick={toggleDrawer}
|
|
||||||
aria-expanded={drawerOpen()}
|
|
||||||
>
|
|
||||||
<For each={visibleColumns()}>
|
|
||||||
{(column) => renderCell(column, host)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Drawer - Additional Info */}
|
|
||||||
<Show when={drawerOpen() && hasDrawerContent()}>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
|
|
||||||
<div class="flex flex-wrap justify-start gap-3">
|
|
||||||
{/* System Info */}
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
|
||||||
<Show when={host.cpuCount}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
<Show when={host.lastSeen}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
Updated {formatRelativeTime(host.lastSeen!)}
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
</p>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.architecture}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.kernelVersion}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={host.agentVersion}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Network Interfaces */}
|
{/* Platform */}
|
||||||
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="text-xs text-gray-700 dark:text-gray-300">
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
<p class="font-medium capitalize whitespace-nowrap">{host.platform || '—'}</p>
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
<Show when={host.osName}>
|
||||||
<For each={host.networkInterfaces?.slice(0, 4)}>
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
|
||||||
{(iface) => (
|
{host.osName} {host.osVersion}
|
||||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
</p>
|
||||||
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
</Show>
|
||||||
<Show when={iface.addresses && iface.addresses.length > 0}>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
</td>
|
||||||
<For each={iface.addresses}>
|
|
||||||
{(addr) => (
|
{/* CPU */}
|
||||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
{addr}
|
<Show when={isMobile()}>
|
||||||
</span>
|
<div class="md:hidden flex justify-center">
|
||||||
)}
|
<MetricText value={cpuPercent} type="cpu" />
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<EnhancedCPUBar
|
||||||
|
usage={cpuPercent}
|
||||||
|
loadAverage={host.loadAverage?.[0]}
|
||||||
|
cores={host.cpuCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Disk Info */}
|
{/* Memory */}
|
||||||
<Show when={host.disks && host.disks.length > 0}>
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<Show when={isMobile()}>
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
<div class="md:hidden flex justify-center">
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
<MetricText value={memPercent} type="memory" />
|
||||||
<For each={host.disks?.slice(0, 3)}>
|
</div>
|
||||||
{(disk) => {
|
</Show>
|
||||||
const diskPercent = () => disk.usage ?? 0;
|
<div class="hidden md:block">
|
||||||
return (
|
<StackedMemoryBar
|
||||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
used={host.memory?.used || 0}
|
||||||
<div class="flex items-center justify-between">
|
total={host.memory?.total || 0}
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
balloon={host.memory?.balloon || 0}
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
swapUsed={host.memory?.swapUsed || 0}
|
||||||
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
swapTotal={host.memory?.swapTotal || 0}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={diskPercent() > 0}>
|
</td>
|
||||||
<div class="mt-1">
|
|
||||||
<MetricBar
|
{/* Temperature */}
|
||||||
value={diskPercent()}
|
<td class="px-2 py-1 align-middle">
|
||||||
label={formatPercent(diskPercent())}
|
<div class="flex justify-center">
|
||||||
type="disk"
|
<Show when={tempValue !== null} fallback={<span class="text-xs text-gray-400">—</span>}>
|
||||||
class="max-w-none"
|
<TemperatureGauge value={tempValue!} />
|
||||||
/>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
|
|
||||||
|
{/* Disk */}
|
||||||
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
|
<Show when={isMobile()}>
|
||||||
|
<div class="md:hidden flex justify-center">
|
||||||
|
<MetricText value={diskStats.percent} type="disk" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<StackedDiskBar
|
||||||
|
disks={host.disks}
|
||||||
|
aggregateDisk={{
|
||||||
|
total: diskStats.total,
|
||||||
|
used: diskStats.used,
|
||||||
|
free: diskStats.total - diskStats.used,
|
||||||
|
usage: diskStats.percent / 100
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<td class="px-2 py-1 pr-4 align-middle">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Show
|
||||||
|
when={host.uptimeSeconds}
|
||||||
|
fallback={<span class="text-xs text-gray-400">—</span>}
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{formatUptime(host.uptimeSeconds!)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Drawer - Additional Info */}
|
||||||
|
<Show when={drawerOpen() && hasDrawerContent()}>
|
||||||
|
<tr>
|
||||||
|
<td colspan={7} class="p-0">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
|
||||||
|
<div class="flex flex-wrap justify-start gap-3">
|
||||||
|
{/* System Info */}
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
|
<Show when={host.cpuCount}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
</Show>
|
||||||
}}
|
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
||||||
</For>
|
<div class="flex items-center justify-between gap-2">
|
||||||
</div>
|
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
||||||
</div>
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
||||||
</Show>
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.architecture}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.kernelVersion}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={host.agentVersion}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
||||||
|
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Temperature Sensors */}
|
{/* Network Interfaces */}
|
||||||
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
|
||||||
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
|
||||||
{([name, temp]) => (
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
|
||||||
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
|
||||||
{temp.toFixed(1)}°C
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* RAID Arrays */}
|
|
||||||
<Show when={host.raid && host.raid.length > 0}>
|
|
||||||
<For each={host.raid!}>
|
|
||||||
{(array) => {
|
|
||||||
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
|
||||||
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
|
||||||
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
|
||||||
|
|
||||||
const stateColor = () => {
|
|
||||||
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
|
||||||
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
|
||||||
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
|
||||||
return 'text-gray-600 dark:text-gray-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
||||||
RAID {array.level.replace('raid', '')} - {array.device}
|
<div class="mt-2 space-y-2 text-[11px]">
|
||||||
</div>
|
<For each={host.networkInterfaces?.slice(0, 4)}>
|
||||||
<div class="mt-2 space-y-1 text-[11px]">
|
{(iface) => (
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
||||||
<span class={stateColor()}>{array.state}</span>
|
<Show when={iface.addresses && iface.addresses.length > 0}>
|
||||||
</div>
|
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<For each={iface.addresses}>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
{(addr) => (
|
||||||
<span class="text-gray-600 dark:text-gray-300">
|
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||||
{array.activeDevices}/{array.totalDevices}
|
{addr}
|
||||||
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</For>
|
||||||
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
</Show>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
|
||||||
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
|
||||||
{array.rebuildPercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Show when={array.rebuildSpeed}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
)}
|
||||||
</Show>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</Show>
|
||||||
}}
|
|
||||||
</For>
|
{/* Disk Info */}
|
||||||
</Show>
|
<Show when={host.disks && host.disks.length > 0}>
|
||||||
</div>
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
||||||
</Show>
|
<div class="mt-2 space-y-2 text-[11px]">
|
||||||
</>
|
<For each={host.disks?.slice(0, 3)}>
|
||||||
);
|
{(disk) => {
|
||||||
}}
|
const diskPercent = () => disk.usage ?? 0;
|
||||||
</For>
|
return (
|
||||||
</div>
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
||||||
|
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={diskPercent() > 0}>
|
||||||
|
<div class="mt-1">
|
||||||
|
<MetricBar
|
||||||
|
value={diskPercent()}
|
||||||
|
label={formatPercent(diskPercent())}
|
||||||
|
type="disk"
|
||||||
|
class="max-w-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Temperature Sensors */}
|
||||||
|
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
|
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
||||||
|
{([name, temp]) => (
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
||||||
|
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
||||||
|
{temp.toFixed(1)}°C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* RAID Arrays */}
|
||||||
|
<Show when={host.raid && host.raid.length > 0}>
|
||||||
|
<For each={host.raid!}>
|
||||||
|
{(array) => {
|
||||||
|
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
||||||
|
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
||||||
|
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
||||||
|
|
||||||
|
const stateColor = () => {
|
||||||
|
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||||
|
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
||||||
|
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
||||||
|
return 'text-gray-600 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
|
RAID {array.level.replace('raid', '')} - {array.device}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1 text-[11px]">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||||
|
<span class={stateColor()}>{array.state}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">
|
||||||
|
{array.activeDevices}/{array.totalDevices}
|
||||||
|
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{array.rebuildPercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Show when={array.rebuildSpeed}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -10,92 +10,11 @@ import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
|
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
|
||||||
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
|
||||||
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
|
|
||||||
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
|
||||||
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
||||||
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
// Icons for mobile headers
|
|
||||||
const ClockIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CpuIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MemoryIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DiskIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TempIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2a2 2 0 00-2 2v9.5a4 4 0 104 0V4a2 2 0 00-2-2z" />
|
|
||||||
<circle cx="12" cy="17" r="2" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const VMIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ContainerIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const BackupIcon = (props: { class?: string }) => (
|
|
||||||
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Column configuration using the new priority system
|
|
||||||
interface ColumnDef {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
priority: ColumnPriority;
|
|
||||||
icon?: Component<{ class?: string }>;
|
|
||||||
minWidth?: string;
|
|
||||||
maxWidth?: string;
|
|
||||||
flex?: number;
|
|
||||||
align?: 'left' | 'center' | 'right';
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_COLUMNS: ColumnDef[] = [
|
|
||||||
{ id: 'name', label: 'Node', priority: 'essential', minWidth: '100px', maxWidth: '300px', align: 'left' },
|
|
||||||
{ id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', flex: 1, align: 'center' },
|
|
||||||
// Metric columns - fixed width to match progress bar max-width (140px + padding)
|
|
||||||
{ id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
|
|
||||||
{ id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
|
|
||||||
{ id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TEMP_COLUMN: ColumnDef = {
|
|
||||||
id: 'temperature',
|
|
||||||
label: 'Temp',
|
|
||||||
priority: 'essential',
|
|
||||||
icon: TempIcon,
|
|
||||||
minWidth: '35px',
|
|
||||||
flex: 1,
|
|
||||||
align: 'center'
|
|
||||||
};
|
|
||||||
|
|
||||||
interface NodeSummaryTableProps {
|
interface NodeSummaryTableProps {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
|
|
@ -114,6 +33,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
const { activeAlerts, state } = useWebSocket();
|
const { activeAlerts, state } = useWebSocket();
|
||||||
const alertsActivation = useAlertsActivation();
|
const alertsActivation = useAlertsActivation();
|
||||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||||
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
|
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
|
||||||
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
|
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
|
||||||
|
|
@ -140,36 +60,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
| 'temperature'
|
| 'temperature'
|
||||||
| CountSortKey;
|
| CountSortKey;
|
||||||
|
|
||||||
interface CountColumn {
|
|
||||||
header: string;
|
|
||||||
key: CountSortKey;
|
|
||||||
icon: Component<{ class?: string }>;
|
|
||||||
minWidth?: string;
|
|
||||||
maxWidth?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [sortKey, setSortKey] = createSignal<SortKey>('default');
|
const [sortKey, setSortKey] = createSignal<SortKey>('default');
|
||||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
const countColumns = createMemo<CountColumn[]>(() => {
|
|
||||||
switch (props.currentTab) {
|
|
||||||
case 'dashboard':
|
|
||||||
return [
|
|
||||||
{ header: 'VMs', key: 'vmCount', icon: VMIcon, minWidth: '40px' },
|
|
||||||
{ header: 'CTs', key: 'containerCount', icon: ContainerIcon, minWidth: '40px' },
|
|
||||||
];
|
|
||||||
case 'storage':
|
|
||||||
return [
|
|
||||||
{ header: 'Storage', key: 'storageCount', icon: DiskIcon, minWidth: '50px' },
|
|
||||||
{ header: 'Disks', key: 'diskCount', icon: DiskIcon, minWidth: '45px' },
|
|
||||||
];
|
|
||||||
case 'backups':
|
|
||||||
return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon, minWidth: '50px' }];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasAnyTemperatureData = createMemo(() => {
|
const hasAnyTemperatureData = createMemo(() => {
|
||||||
return (
|
return (
|
||||||
props.nodes?.some(
|
props.nodes?.some(
|
||||||
|
|
@ -178,34 +71,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build dynamic columns list
|
|
||||||
const columns = createMemo<ColumnDef[]>(() => {
|
|
||||||
const cols = [...BASE_COLUMNS];
|
|
||||||
|
|
||||||
if (hasAnyTemperatureData()) {
|
|
||||||
cols.push(TEMP_COLUMN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add count columns based on tab - these share extra space evenly with other flex columns
|
|
||||||
countColumns().forEach((cc) => {
|
|
||||||
cols.push({
|
|
||||||
id: cc.key,
|
|
||||||
label: cc.header,
|
|
||||||
priority: 'essential' as ColumnPriority,
|
|
||||||
icon: cc.icon,
|
|
||||||
minWidth: cc.minWidth || '40px',
|
|
||||||
flex: 1,
|
|
||||||
align: 'center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return cols;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the responsive grid template hook for dynamic column visibility
|
|
||||||
// Pass the columns accessor to support reactive column changes
|
|
||||||
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns });
|
|
||||||
|
|
||||||
const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
|
const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
|
||||||
|
|
||||||
const vmCountsByNode = createMemo<Record<string, number>>(() => {
|
const vmCountsByNode = createMemo<Record<string, number>>(() => {
|
||||||
|
|
@ -465,225 +330,221 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header cell component
|
const renderSortIndicator = (key: SortKey) => {
|
||||||
const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => {
|
if (sortKey() !== key) return null;
|
||||||
const Icon = cellProps.column.icon;
|
return sortDirection() === 'asc' ? '▲' : '▼';
|
||||||
const isSorted = () => sortKey() === cellProps.column.id;
|
|
||||||
const sortIndicator = () => isSorted() ? (sortDirection() === 'asc' ? '▲' : '▼') : '';
|
|
||||||
|
|
||||||
const alignClass = () => {
|
|
||||||
if (cellProps.column.align === 'center') return 'justify-center text-center';
|
|
||||||
if (cellProps.column.align === 'right') return 'justify-end text-right';
|
|
||||||
return 'justify-start text-left';
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseClass = `px-0.5 md:px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full ${alignClass()}`;
|
|
||||||
const nameClass = cellProps.column.id === 'name' ? 'pl-3' : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`${baseClass} ${nameClass}`}
|
|
||||||
onClick={() => handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
|
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
|
||||||
>
|
|
||||||
<Show when={Icon !== undefined && isMobile()}>
|
|
||||||
<span class="md:hidden" title={cellProps.column.label}>
|
|
||||||
{Icon && <Icon class="w-4 h-4" />}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class={Icon !== undefined && isMobile() ? 'hidden md:inline' : ''}>
|
|
||||||
{cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label}
|
|
||||||
</span>
|
|
||||||
<Show when={sortIndicator()}>
|
|
||||||
<span class="ml-0.5">{sortIndicator()}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
||||||
<div>
|
<div class="overflow-x-auto">
|
||||||
{/* Header */}
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<div
|
<thead>
|
||||||
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20"
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||||
style={{ 'grid-template-columns': gridTemplate() }}
|
<th
|
||||||
>
|
class={`${thClass} text-left pl-3`}
|
||||||
<For each={visibleColumns()}>
|
onClick={() => handleSort('name')}
|
||||||
{(column) => (
|
>
|
||||||
<HeaderCell column={column} />
|
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
|
||||||
)}
|
</th>
|
||||||
</For>
|
<th class={thClass} onClick={() => handleSort('uptime')}>
|
||||||
</div>
|
Uptime {renderSortIndicator('uptime')}
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('cpu')}>
|
||||||
|
CPU {renderSortIndicator('cpu')}
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('memory')}>
|
||||||
|
Memory {renderSortIndicator('memory')}
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('disk')}>
|
||||||
|
Disk {renderSortIndicator('disk')}
|
||||||
|
</th>
|
||||||
|
<Show when={hasAnyTemperatureData()}>
|
||||||
|
<th class={thClass} onClick={() => handleSort('temperature')}>
|
||||||
|
Temp {renderSortIndicator('temperature')}
|
||||||
|
</th>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.currentTab === 'dashboard'}>
|
||||||
|
<th class={thClass} onClick={() => handleSort('vmCount')}>
|
||||||
|
VMs {renderSortIndicator('vmCount')}
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('containerCount')}>
|
||||||
|
CTs {renderSortIndicator('containerCount')}
|
||||||
|
</th>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.currentTab === 'storage'}>
|
||||||
|
<th class={thClass} onClick={() => handleSort('storageCount')}>
|
||||||
|
Storage {renderSortIndicator('storageCount')}
|
||||||
|
</th>
|
||||||
|
<th class={thClass} onClick={() => handleSort('diskCount')}>
|
||||||
|
Disks {renderSortIndicator('diskCount')}
|
||||||
|
</th>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.currentTab === 'backups'}>
|
||||||
|
<th class={thClass} onClick={() => handleSort('backupCount')}>
|
||||||
|
Backups {renderSortIndicator('backupCount')}
|
||||||
|
</th>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<For each={sortedItems()}>
|
||||||
|
{(item) => {
|
||||||
|
const isPVEItem = isPVE(item);
|
||||||
|
const isPBSItem = !isPVEItem;
|
||||||
|
const node = isPVEItem ? (item as Node) : null;
|
||||||
|
const pbs = isPBSItem ? (item as PBSInstance) : null;
|
||||||
|
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
const online = isItemOnline(item);
|
||||||
<For each={sortedItems()}>
|
const statusIndicator = createMemo(() =>
|
||||||
{(item) => {
|
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
||||||
const isPVEItem = isPVE(item);
|
);
|
||||||
const isPBSItem = !isPVEItem;
|
const cpuPercentValue = getCpuPercent(item);
|
||||||
const node = isPVEItem ? (item as Node) : null;
|
const memoryPercentValue = getMemoryPercent(item);
|
||||||
const pbs = isPBSItem ? (item as PBSInstance) : null;
|
const diskPercentValue = getDiskPercent(item);
|
||||||
|
const diskSublabel = getDiskSublabel(item);
|
||||||
|
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
||||||
|
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
|
||||||
|
const displayName = () => {
|
||||||
|
if (isPVEItem) return getNodeDisplayName(node as Node);
|
||||||
|
return (pbs as PBSInstance).name;
|
||||||
|
};
|
||||||
|
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
|
||||||
|
|
||||||
const online = isItemOnline(item);
|
const nodeId = isPVEItem ? node!.id : pbs!.name;
|
||||||
const statusIndicator = createMemo(() =>
|
const isSelected = () => props.selectedNode === nodeId;
|
||||||
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
|
||||||
);
|
const metricsKey = buildMetricKey('node', resourceId);
|
||||||
const cpuPercentValue = getCpuPercent(item);
|
const alertStyles = createMemo(() =>
|
||||||
const memoryPercentValue = getMemoryPercent(item);
|
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
||||||
const diskPercentValue = getDiskPercent(item);
|
);
|
||||||
const diskSublabel = getDiskSublabel(item);
|
const showAlertHighlight = createMemo(
|
||||||
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
() => alertStyles().hasUnacknowledgedAlert && online,
|
||||||
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
|
);
|
||||||
const displayName = () => {
|
|
||||||
if (isPVEItem) return getNodeDisplayName(node as Node);
|
|
||||||
return (pbs as PBSInstance).name;
|
|
||||||
};
|
|
||||||
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
|
|
||||||
|
|
||||||
const nodeId = isPVEItem ? node!.id : pbs!.name;
|
const rowStyle = createMemo(() => {
|
||||||
const isSelected = () => props.selectedNode === nodeId;
|
const styles: Record<string, string> = {};
|
||||||
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
|
const shadows: string[] = [];
|
||||||
const metricsKey = buildMetricKey('node', resourceId);
|
|
||||||
const alertStyles = createMemo(() =>
|
|
||||||
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
|
||||||
);
|
|
||||||
const showAlertHighlight = createMemo(
|
|
||||||
() => alertStyles().hasUnacknowledgedAlert && online,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowStyle = createMemo(() => {
|
if (showAlertHighlight()) {
|
||||||
const styles: Record<string, string> = {};
|
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||||
const shadows: string[] = [];
|
shadows.push(`inset 4px 0 0 0 ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
if (isSelected()) {
|
||||||
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
||||||
shadows.push(`inset 4px 0 0 0 ${color}`);
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected()) {
|
if (shadows.length > 0) {
|
||||||
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
styles['box-shadow'] = shadows.join(', ');
|
||||||
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (shadows.length > 0) {
|
return styles;
|
||||||
styles['box-shadow'] = shadows.join(', ');
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return styles;
|
const rowClass = createMemo(() => {
|
||||||
});
|
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
||||||
|
|
||||||
const rowClass = createMemo(() => {
|
if (isSelected()) {
|
||||||
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSelected()) {
|
if (showAlertHighlight()) {
|
||||||
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;
|
const alertBg = alertStyles().severity === 'critical'
|
||||||
}
|
? 'bg-red-50 dark:bg-red-950/30'
|
||||||
|
: 'bg-yellow-50 dark:bg-yellow-950/20';
|
||||||
|
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm group ${alertBg}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
let className = baseHover;
|
||||||
return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
|
||||||
}
|
|
||||||
|
|
||||||
let className = baseHover;
|
if (props.selectedNode && props.selectedNode !== nodeId) {
|
||||||
|
className += ' opacity-50 hover:opacity-80';
|
||||||
|
}
|
||||||
|
|
||||||
if (props.selectedNode && props.selectedNode !== nodeId) {
|
if (!online) {
|
||||||
className += ' opacity-50 hover:opacity-80';
|
className += ' opacity-60';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!online) {
|
return className;
|
||||||
className += ' opacity-60';
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return className;
|
return (
|
||||||
});
|
<tr
|
||||||
|
class={rowClass()}
|
||||||
const cellBgClass = createMemo(() => {
|
style={rowStyle()}
|
||||||
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30';
|
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
|
||||||
if (showAlertHighlight()) {
|
>
|
||||||
return alertStyles().severity === 'critical'
|
{/* Name */}
|
||||||
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40'
|
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
|
||||||
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30';
|
<div class="flex items-center gap-1.5">
|
||||||
}
|
<StatusDot
|
||||||
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50';
|
variant={statusIndicator().variant}
|
||||||
});
|
title={statusIndicator().label}
|
||||||
|
ariaLabel={statusIndicator().label}
|
||||||
// Render cell content based on column type
|
size="xs"
|
||||||
const renderCell = (column: ColumnDef) => {
|
/>
|
||||||
const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`;
|
<a
|
||||||
const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start';
|
href={
|
||||||
|
isPVEItem
|
||||||
switch (column.id) {
|
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
||||||
case 'name':
|
: pbs!.host || `https://${pbs!.name}:8007`
|
||||||
return (
|
}
|
||||||
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
|
target="_blank"
|
||||||
<div class="flex items-center gap-1.5">
|
onClick={(e) => e.stopPropagation()}
|
||||||
<StatusDot
|
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 whitespace-nowrap"
|
||||||
variant={statusIndicator().variant}
|
title={displayName()}
|
||||||
title={statusIndicator().label}
|
>
|
||||||
ariaLabel={statusIndicator().label}
|
{displayName()}
|
||||||
size="xs"
|
</a>
|
||||||
/>
|
<Show when={showActualName()}>
|
||||||
<a
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
href={
|
({(node as Node).name})
|
||||||
isPVEItem
|
</span>
|
||||||
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
</Show>
|
||||||
: pbs!.host || `https://${pbs!.name}:8007`
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
||||||
}
|
<Show when={isPVEItem}>
|
||||||
target="_blank"
|
<span class="text-[9px] px-1 py-0 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||||
onClick={(e) => e.stopPropagation()}
|
PVE
|
||||||
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate"
|
</span>
|
||||||
title={displayName()}
|
</Show>
|
||||||
>
|
<Show when={isPVEItem && node!.pveVersion}>
|
||||||
{displayName()}
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
</a>
|
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
||||||
<Show when={showActualName()}>
|
</span>
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
|
</Show>
|
||||||
({(node as Node).name})
|
<Show when={isPVEItem && node!.isClusterMember !== undefined}>
|
||||||
|
<span
|
||||||
|
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${node!.isClusterMember
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBSItem}>
|
||||||
|
<span class="text-[9px] px-1 py-0 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||||
|
PBS
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBSItem && pbs!.version}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
v{pbs!.version}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
|
||||||
<Show when={isPVEItem}>
|
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
|
||||||
PVE
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPVEItem && node!.pveVersion}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPVEItem && node!.isClusterMember !== undefined}>
|
|
||||||
<span
|
|
||||||
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPBSItem}>
|
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
|
||||||
PBS
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPBSItem && pbs!.version}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
v{pbs!.version}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'uptime':
|
{/* Uptime */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
<div class="flex justify-center">
|
||||||
<span
|
<span
|
||||||
class={`text-xs ${isPVEItem && (node?.uptime ?? 0) < 3600
|
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
|
||||||
? 'text-orange-500'
|
? 'text-orange-500'
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -695,169 +556,182 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'cpu':
|
{/* CPU */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class={`${baseCellClass} ${alignClass}`}>
|
<Show when={isMobile()}>
|
||||||
<Show when={isMobile()}>
|
<div class="md:hidden flex justify-center">
|
||||||
<div class="md:hidden w-full">
|
<MetricText value={cpuPercentValue} type="cpu" />
|
||||||
<MetricText value={cpuPercentValue} type="cpu" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<Show when={isPVEItem} fallback={
|
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={cpuPercentValue}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
isRunning={online}
|
|
||||||
showMobile={false}
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<EnhancedCPUBar
|
|
||||||
usage={cpuPercentValue}
|
|
||||||
loadAverage={node!.loadAverage?.[0]}
|
|
||||||
cores={node!.cpuInfo?.cores}
|
|
||||||
model={node!.cpuInfo?.model}
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
);
|
<div class="hidden md:block">
|
||||||
|
<Show when={isPVEItem} fallback={
|
||||||
case 'memory':
|
<ResponsiveMetricCell
|
||||||
return (
|
value={cpuPercentValue}
|
||||||
<div class={`${baseCellClass} ${alignClass}`}>
|
type="cpu"
|
||||||
<Show when={isMobile()}>
|
resourceId={metricsKey}
|
||||||
<div class="md:hidden w-full">
|
isRunning={online}
|
||||||
<MetricText value={memoryPercentValue} type="memory" />
|
showMobile={false}
|
||||||
</div>
|
/>
|
||||||
|
}>
|
||||||
|
<EnhancedCPUBar
|
||||||
|
usage={cpuPercentValue}
|
||||||
|
loadAverage={node!.loadAverage?.[0]}
|
||||||
|
cores={node!.cpuInfo?.cores}
|
||||||
|
model={node!.cpuInfo?.model}
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="hidden md:block w-full">
|
</div>
|
||||||
<Show when={isPVEItem} fallback={
|
</td>
|
||||||
<ResponsiveMetricCell
|
|
||||||
value={memoryPercentValue}
|
{/* Memory */}
|
||||||
type="memory"
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
resourceId={metricsKey}
|
<Show when={isMobile()}>
|
||||||
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
|
<div class="md:hidden flex justify-center">
|
||||||
isRunning={online}
|
<MetricText value={memoryPercentValue} type="memory" />
|
||||||
showMobile={false}
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<StackedMemoryBar
|
|
||||||
used={node!.memory?.used || 0}
|
|
||||||
total={node!.memory?.total || 0}
|
|
||||||
balloon={node!.memory?.balloon || 0}
|
|
||||||
swapUsed={node!.memory?.swapUsed || 0}
|
|
||||||
swapTotal={node!.memory?.swapTotal || 0}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<Show when={isPVEItem} fallback={
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={memoryPercentValue}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
|
||||||
|
isRunning={online}
|
||||||
|
showMobile={false}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
<StackedMemoryBar
|
||||||
|
used={node!.memory?.used || 0}
|
||||||
|
total={node!.memory?.total || 0}
|
||||||
|
balloon={node!.memory?.balloon || 0}
|
||||||
|
swapUsed={node!.memory?.swapUsed || 0}
|
||||||
|
swapTotal={node!.memory?.swapTotal || 0}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
|
|
||||||
case 'disk':
|
{/* Disk */}
|
||||||
return (
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class={`${baseCellClass} ${alignClass}`}>
|
<ResponsiveMetricCell
|
||||||
<ResponsiveMetricCell
|
value={diskPercentValue}
|
||||||
value={diskPercentValue}
|
type="disk"
|
||||||
type="disk"
|
resourceId={metricsKey}
|
||||||
resourceId={metricsKey}
|
sublabel={diskSublabel}
|
||||||
sublabel={diskSublabel}
|
isRunning={online}
|
||||||
isRunning={online}
|
showMobile={isMobile()}
|
||||||
showMobile={isMobile()}
|
/>
|
||||||
class="w-full"
|
</td>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'temperature':
|
{/* Temperature */}
|
||||||
return (
|
<Show when={hasAnyTemperatureData()}>
|
||||||
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show
|
<div class="flex justify-center">
|
||||||
when={
|
<Show
|
||||||
online &&
|
when={
|
||||||
isPVEItem &&
|
online &&
|
||||||
cpuTemperatureValue !== null &&
|
isPVEItem &&
|
||||||
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
cpuTemperatureValue !== null &&
|
||||||
isTemperatureMonitoringEnabled(node!)
|
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
||||||
}
|
isTemperatureMonitoringEnabled(node!)
|
||||||
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
|
}
|
||||||
>
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
|
||||||
{(() => {
|
>
|
||||||
const value = cpuTemperatureValue as number;
|
{(() => {
|
||||||
const temp = node!.temperature;
|
const value = cpuTemperatureValue as number;
|
||||||
const cpuMinValue =
|
const temp = node!.temperature;
|
||||||
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
const cpuMinValue =
|
||||||
const cpuMaxValue =
|
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
||||||
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
const cpuMaxValue =
|
||||||
? temp.cpuMaxRecord
|
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
||||||
: null;
|
? temp.cpuMaxRecord
|
||||||
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
: null;
|
||||||
|
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
||||||
|
|
||||||
const gpus = temp?.gpu ?? [];
|
const gpus = temp?.gpu ?? [];
|
||||||
const hasGPU = gpus.length > 0;
|
const hasGPU = gpus.length > 0;
|
||||||
|
|
||||||
if (hasMinMax || hasGPU) {
|
if (hasMinMax || hasGPU) {
|
||||||
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
|
const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined;
|
||||||
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
|
const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
|
||||||
|
<TemperatureGauge
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
|
<TemperatureGauge value={value} />
|
||||||
<TemperatureGauge
|
|
||||||
value={value}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
})()}
|
||||||
|
</Show>
|
||||||
return (
|
|
||||||
<TemperatureGauge value={value} />
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count columns (vmCount, containerCount, storageCount, diskCount, backupCount)
|
|
||||||
default:
|
|
||||||
if (column.id.endsWith('Count')) {
|
|
||||||
const value = getCountValue(item, column.id as CountSortKey);
|
|
||||||
const display = online ? value ?? '-' : '-';
|
|
||||||
const textClass = online
|
|
||||||
? 'text-xs text-gray-700 dark:text-gray-300'
|
|
||||||
: 'text-xs text-gray-400 dark:text-gray-500';
|
|
||||||
return (
|
|
||||||
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
|
||||||
<span class={textClass}>{display}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</td>
|
||||||
}
|
</Show>
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
{/* Dashboard tab: VMs and CTs */}
|
||||||
<div
|
<Show when={props.currentTab === 'dashboard'}>
|
||||||
class={`${rowClass()} grid items-center`}
|
<td class="px-2 py-1 align-middle">
|
||||||
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
<div class="flex justify-center">
|
||||||
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
|
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
|
||||||
>
|
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
|
||||||
<For each={visibleColumns()}>
|
</span>
|
||||||
{(column) => renderCell(column)}
|
</div>
|
||||||
</For>
|
</td>
|
||||||
</div>
|
<td class="px-2 py-1 align-middle">
|
||||||
);
|
<div class="flex justify-center">
|
||||||
}}
|
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
|
||||||
</For >
|
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
|
||||||
</div >
|
</span>
|
||||||
</div >
|
</div>
|
||||||
</Card >
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Storage tab: Storage and Disks */}
|
||||||
|
<Show when={props.currentTab === 'storage'}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
|
||||||
|
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
|
||||||
|
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Backups tab: Backups */}
|
||||||
|
<Show when={props.currentTab === 'backups'}>
|
||||||
|
<td class="px-2 py-1 align-middle">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
|
||||||
|
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, createMemo, Show } from 'solid-js';
|
import { Component, createMemo } from 'solid-js';
|
||||||
|
|
||||||
interface TemperatureGaugeProps {
|
interface TemperatureGaugeProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -6,7 +6,6 @@ interface TemperatureGaugeProps {
|
||||||
max?: number | null;
|
max?: number | null;
|
||||||
critical?: number;
|
critical?: number;
|
||||||
warning?: number;
|
warning?: number;
|
||||||
label?: string;
|
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,54 +13,15 @@ export const TemperatureGauge: Component<TemperatureGaugeProps> = (props) => {
|
||||||
const critical = props.critical ?? 80;
|
const critical = props.critical ?? 80;
|
||||||
const warning = props.warning ?? 70;
|
const warning = props.warning ?? 70;
|
||||||
|
|
||||||
// Calculate percentage (assuming 0-100°C range for simplicity, or slightly dynamic)
|
|
||||||
// Most CPUs idle around 30-40, max out at 100.
|
|
||||||
const percent = createMemo(() => Math.min(100, Math.max(0, props.value)));
|
|
||||||
|
|
||||||
const colorClass = createMemo(() => {
|
|
||||||
if (props.value >= critical) return 'bg-red-500 dark:bg-red-500';
|
|
||||||
if (props.value >= warning) return 'bg-yellow-500 dark:bg-yellow-500';
|
|
||||||
return 'bg-green-500 dark:bg-green-500';
|
|
||||||
});
|
|
||||||
|
|
||||||
const textColorClass = createMemo(() => {
|
const textColorClass = createMemo(() => {
|
||||||
if (props.value >= critical) return 'text-red-700 dark:text-red-400';
|
if (props.value >= critical) return 'text-red-600 dark:text-red-400';
|
||||||
if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400';
|
if (props.value >= warning) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
return 'text-green-700 dark:text-green-400';
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex items-center gap-2 ${props.class || ''}`}>
|
<span class={`text-xs whitespace-nowrap ${textColorClass()} ${props.class || ''}`}>
|
||||||
{/* Text Value */}
|
{Math.round(props.value)}°C
|
||||||
<span class={`text-xs font-medium w-[36px] text-right ${textColorClass()}`}>
|
</span>
|
||||||
{Math.round(props.value)}°C
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Bar */}
|
|
||||||
<div class="relative flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden min-w-[40px] max-w-[80px]">
|
|
||||||
<div
|
|
||||||
class={`h-full rounded-full transition-all duration-500 ${colorClass()}`}
|
|
||||||
style={{ width: `${percent()}%` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Min Marker */}
|
|
||||||
<Show when={props.min !== null && props.min !== undefined}>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 bottom-0 w-0.5 bg-blue-400 opacity-70"
|
|
||||||
style={{ left: `${Math.min(100, Math.max(0, props.min!))}%` }}
|
|
||||||
title={`Min: ${Math.round(props.min!)}°C`}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Max Marker */}
|
|
||||||
<Show when={props.max !== null && props.max !== undefined}>
|
|
||||||
<div
|
|
||||||
class="absolute top-0 bottom-0 w-0.5 bg-red-400 opacity-70"
|
|
||||||
style={{ left: `${Math.min(100, Math.max(0, props.max!))}%` }}
|
|
||||||
title={`Max: ${Math.round(props.max!)}°C`}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export interface GridTemplateOptions {
|
||||||
|
|
||||||
/** Custom gap between columns (CSS value) */
|
/** Custom gap between columns (CSS value) */
|
||||||
gap?: string;
|
gap?: string;
|
||||||
|
|
||||||
|
/** If true, show all columns regardless of breakpoint (use horizontal scroll instead) */
|
||||||
|
disableColumnHiding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridTemplateResult {
|
export interface GridTemplateResult {
|
||||||
|
|
@ -38,6 +41,16 @@ function generateGridTemplate(columns: ColumnConfig[]): string {
|
||||||
const min = col.minWidth || '50px';
|
const min = col.minWidth || '50px';
|
||||||
const max = col.maxWidth || `${col.flex || 1}fr`;
|
const max = col.maxWidth || `${col.flex || 1}fr`;
|
||||||
|
|
||||||
|
// If both min and max are 'auto', use just 'auto' for content-based sizing
|
||||||
|
if (min === 'auto' && max === 'auto') {
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only max is 'auto', use minmax with auto
|
||||||
|
if (max === 'auto') {
|
||||||
|
return `minmax(${min}, auto)`;
|
||||||
|
}
|
||||||
|
|
||||||
// If we have both min and max as fixed values, use the appropriate one
|
// If we have both min and max as fixed values, use the appropriate one
|
||||||
if (col.maxWidth && !col.maxWidth.includes('fr')) {
|
if (col.maxWidth && !col.maxWidth.includes('fr')) {
|
||||||
return `minmax(${min}, ${col.maxWidth})`;
|
return `minmax(${min}, ${col.maxWidth})`;
|
||||||
|
|
@ -100,7 +113,11 @@ export function useGridTemplate(options: GridTemplateOptions): GridTemplateResul
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleColumns = createMemo(() => {
|
const visibleColumns = createMemo(() => {
|
||||||
return getVisibleColumns(getColumns(), breakpoint());
|
const cols = getColumns();
|
||||||
|
if (options.disableColumnHiding) {
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
return getVisibleColumns(cols, breakpoint());
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridTemplate = createMemo(() => {
|
const gridTemplate = createMemo(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue