- 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
398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
|
import { StackedContainerBar } from './StackedContainerBar';
|
|
import type { DockerHost } from '@/types/api';
|
|
import { Card } from '@/components/shared/Card';
|
|
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
|
import { resolveHostRuntime } from './runtimeDisplay';
|
|
import { formatUptime } from '@/utils/format';
|
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
|
import { StatusDot } from '@/components/shared/StatusDot';
|
|
import { getDockerHostStatusIndicator } from '@/utils/status';
|
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
|
import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive';
|
|
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
|
|
|
|
export interface DockerHostSummary {
|
|
host: DockerHost;
|
|
cpuPercent: number;
|
|
memoryPercent: number;
|
|
memoryLabel?: string;
|
|
diskPercent: number;
|
|
diskLabel?: string;
|
|
runningPercent: number;
|
|
runningCount: number;
|
|
stoppedCount: number;
|
|
errorCount: number;
|
|
totalCount: number;
|
|
uptimeSeconds: number;
|
|
lastSeenRelative: string;
|
|
lastSeenAbsolute: string;
|
|
}
|
|
|
|
interface DockerHostSummaryTableProps {
|
|
summaries: () => DockerHostSummary[];
|
|
selectedHostId: () => string | null;
|
|
onSelect: (hostId: string) => void;
|
|
}
|
|
|
|
type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent';
|
|
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
|
const isHostOnline = (host: DockerHost) => {
|
|
const status = host.status?.toLowerCase() ?? '';
|
|
return status === 'online' || status === 'running' || status === 'healthy' || status === 'degraded';
|
|
};
|
|
|
|
const getDisplayName = (host: DockerHost) => {
|
|
return host.customDisplayName || host.displayName || host.hostname || host.id;
|
|
};
|
|
|
|
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
|
|
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
|
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
|
|
const { isMobile } = useBreakpoint();
|
|
|
|
const handleSort = (key: SortKey) => {
|
|
if (sortKey() === key) {
|
|
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDirection(key === 'name' ? 'asc' : 'desc');
|
|
}
|
|
};
|
|
|
|
const formatLastSeenValue = (lastSeen?: number) => {
|
|
if (!lastSeen) return 0;
|
|
return lastSeen;
|
|
};
|
|
|
|
|
|
|
|
const sortedSummaries = createMemo(() => {
|
|
const list = [...props.summaries()];
|
|
const key = sortKey();
|
|
const dir = sortDirection();
|
|
|
|
list.sort((a, b) => {
|
|
const hostA = a.host;
|
|
const hostB = b.host;
|
|
|
|
let value = 0;
|
|
switch (key) {
|
|
case 'name':
|
|
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
|
|
break;
|
|
case 'uptime':
|
|
value = (a.uptimeSeconds || 0) - (b.uptimeSeconds || 0);
|
|
break;
|
|
case 'cpu':
|
|
value = a.cpuPercent - b.cpuPercent;
|
|
break;
|
|
case 'memory':
|
|
value = a.memoryPercent - b.memoryPercent;
|
|
break;
|
|
case 'disk':
|
|
value = a.diskPercent - b.diskPercent;
|
|
break;
|
|
case 'running':
|
|
value = a.runningPercent - b.runningPercent;
|
|
break;
|
|
case 'lastSeen':
|
|
value = formatLastSeenValue(hostA.lastSeen) - formatLastSeenValue(hostB.lastSeen);
|
|
break;
|
|
case 'agent':
|
|
// Sort by version, putting outdated versions first (for easy identification)
|
|
const aOutdated = isAgentOutdated(hostA.agentVersion);
|
|
const bOutdated = isAgentOutdated(hostB.agentVersion);
|
|
if (aOutdated !== bOutdated) {
|
|
value = aOutdated ? -1 : 1;
|
|
} else {
|
|
value = (hostA.agentVersion || '').localeCompare(hostB.agentVersion || '');
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (value === 0) {
|
|
value = getDisplayName(hostA).localeCompare(getDisplayName(hostB));
|
|
}
|
|
|
|
return dir === 'asc' ? value : -value;
|
|
});
|
|
|
|
return list;
|
|
});
|
|
|
|
const renderSortIndicator = (key: SortKey) => {
|
|
if (sortKey() !== key) return null;
|
|
return sortDirection() === 'asc' ? '▲' : '▼';
|
|
};
|
|
|
|
const isAgentOutdated = (version?: string) => {
|
|
if (!version) return false;
|
|
return version.includes('dev') || version.startsWith('0.1');
|
|
};
|
|
|
|
return (
|
|
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
|
<ScrollableTable persistKey="docker-host-summary">
|
|
<table class="w-full border-collapse whitespace-nowrap">
|
|
<thead>
|
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
|
<th
|
|
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
|
onClick={() => handleSort('name')}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
|
>
|
|
Host {renderSortIndicator('name')}
|
|
</th>
|
|
<th class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap">
|
|
Status
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('cpu')}
|
|
>
|
|
CPU {renderSortIndicator('cpu')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('memory')}
|
|
>
|
|
Memory {renderSortIndicator('memory')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('disk')}
|
|
>
|
|
Disk {renderSortIndicator('disk')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('running')}
|
|
>
|
|
Containers {renderSortIndicator('running')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('uptime')}
|
|
>
|
|
Uptime {renderSortIndicator('uptime')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('lastSeen')}
|
|
>
|
|
Last Update {renderSortIndicator('lastSeen')}
|
|
</th>
|
|
<th
|
|
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
onClick={() => handleSort('agent')}
|
|
>
|
|
Agent {renderSortIndicator('agent')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<For each={sortedSummaries()}>
|
|
{(summary) => {
|
|
const selected = props.selectedHostId() === summary.host.id;
|
|
const online = isHostOnline(summary.host);
|
|
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
|
|
|
const rowStyle = () => {
|
|
const styles: Record<string, string> = {};
|
|
const shadows: string[] = [];
|
|
|
|
if (selected) {
|
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
|
}
|
|
|
|
if (shadows.length > 0) {
|
|
styles['box-shadow'] = shadows.join(', ');
|
|
}
|
|
return styles;
|
|
};
|
|
|
|
const rowClass = () => {
|
|
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
|
|
|
|
if (selected) {
|
|
return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';
|
|
}
|
|
|
|
let className = baseHover;
|
|
|
|
if (!online) {
|
|
className += ' opacity-60';
|
|
}
|
|
|
|
return className;
|
|
};
|
|
|
|
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
|
|
const runtimeInfo = resolveHostRuntime(summary.host);
|
|
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
|
|
const metricsKey = buildMetricKey('dockerHost', summary.host.id);
|
|
const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host));
|
|
|
|
return (
|
|
<tr
|
|
class={rowClass()}
|
|
style={rowStyle()}
|
|
onClick={() => props.onSelect(summary.host.id)}
|
|
>
|
|
<td class="pr-2 py-1 pl-3 align-middle">
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
<StatusDot
|
|
variant={hostStatus().variant}
|
|
title={hostStatus().label}
|
|
ariaLabel={hostStatus().label}
|
|
size="xs"
|
|
/>
|
|
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100 truncate" title={getDisplayName(summary.host)}>
|
|
{getDisplayName(summary.host)}
|
|
</span>
|
|
<Show when={getDisplayName(summary.host) !== summary.host.hostname}>
|
|
<span class="hidden sm:inline text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
({summary.host.hostname})
|
|
</span>
|
|
</Show>
|
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
|
<span
|
|
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
|
|
title={runtimeInfo.raw || runtimeInfo.label}
|
|
>
|
|
{runtimeInfo.label}
|
|
</span>
|
|
<Show when={runtimeVersion}>
|
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
v{runtimeVersion}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle">
|
|
<div class="flex justify-center items-center h-full w-full whitespace-nowrap">
|
|
{renderDockerStatusBadge(summary.host.status)}
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
|
<Show when={isMobile()}>
|
|
<div class="md:hidden flex justify-center">
|
|
<MetricText value={summary.cpuPercent} type="cpu" />
|
|
</div>
|
|
</Show>
|
|
<div class="hidden md:block w-full">
|
|
<EnhancedCPUBar
|
|
usage={summary.cpuPercent}
|
|
loadAverage={summary.host.loadAverage?.[0]}
|
|
cores={summary.host.cpus}
|
|
resourceId={metricsKey}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
|
<ResponsiveMetricCell
|
|
value={summary.memoryPercent}
|
|
type="memory"
|
|
sublabel={summary.memoryLabel}
|
|
resourceId={metricsKey}
|
|
isRunning={online}
|
|
showMobile={isMobile()}
|
|
/>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
|
<ResponsiveMetricCell
|
|
value={summary.diskPercent}
|
|
type="disk"
|
|
sublabel={summary.diskLabel}
|
|
resourceId={metricsKey}
|
|
isRunning={!!summary.diskLabel}
|
|
showMobile={isMobile()}
|
|
/>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle">
|
|
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
|
|
<Show
|
|
when={summary.totalCount > 0}
|
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
|
>
|
|
<StackedContainerBar
|
|
running={summary.runningCount}
|
|
stopped={summary.stoppedCount}
|
|
error={summary.errorCount}
|
|
total={summary.totalCount}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle whitespace-nowrap">
|
|
<div class="flex justify-center items-center h-full">
|
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
|
{uptimeLabel}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle">
|
|
<div class="flex justify-center items-center h-full">
|
|
<Show
|
|
when={summary.lastSeenRelative}
|
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}
|
|
>
|
|
{(relative) => (
|
|
<span
|
|
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
|
|
title={summary.lastSeenAbsolute || undefined}
|
|
>
|
|
{relative()}
|
|
</span>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1 align-middle">
|
|
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
|
|
<Show
|
|
when={summary.host.agentVersion}
|
|
fallback={<span class="text-gray-400 dark:text-gray-500 text-xs">—</span>}
|
|
>
|
|
{(version) => (
|
|
<span
|
|
class={
|
|
agentOutdated
|
|
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
|
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
|
}
|
|
title={`${agentOutdated
|
|
? 'Agent is outdated on this host'
|
|
: 'Agent is up to date'
|
|
}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
|
>
|
|
{version()}
|
|
</span>
|
|
)}
|
|
</Show>
|
|
<Show when={agentOutdated}>
|
|
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
|
|
⚠
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</ScrollableTable>
|
|
</Card>
|
|
);
|
|
};
|