import type { Component } from 'solid-js'; import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import type { DockerHost } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { DockerFilter } from './DockerFilter'; import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSummaryTable'; import { DockerUnifiedTable } from './DockerUnifiedTable'; import { useWebSocket } from '@/App'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { formatBytes, formatRelativeTime } from '@/utils/format'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; import { logger } from '@/utils/logger'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status'; type DockerMetadataRecord = Record; interface DockerHostsProps { hosts: DockerHost[]; activeAlerts?: Record | any; } export const DockerHosts: Component = (props) => { const navigate = useNavigate(); const { initialDataReceived, reconnecting, connected } = useWebSocket(); // Load docker metadata from localStorage or API const loadInitialDockerMetadata = (): DockerMetadataRecord => { try { const cached = localStorage.getItem(STORAGE_KEYS.DOCKER_METADATA); if (cached) { return JSON.parse(cached); } } catch (err) { logger.warn('Failed to parse cached docker metadata', err); } return {}; }; const [dockerMetadata, setDockerMetadata] = createSignal( loadInitialDockerMetadata(), ); const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { const aName = a.customDisplayName || a.displayName || a.hostname || a.id || ''; const bName = b.customDisplayName || b.displayName || b.hostname || b.id || ''; return aName.localeCompare(bName); }); }); const isLoading = createMemo(() => { if (typeof initialDataReceived === 'function') { const hostCount = Array.isArray(props.hosts) ? props.hosts.length : 0; return !initialDataReceived() && hostCount === 0; } return false; }); const [search, setSearch] = createSignal(''); const debouncedSearch = useDebouncedValue(search, 250); const [selectedHostId, setSelectedHostId] = createSignal(null); const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); const clampPercent = (value: number | undefined | null) => { if (value === undefined || value === null || Number.isNaN(value)) return 0; if (!Number.isFinite(value)) return 0; if (value < 0) return 0; if (value > 100) return 100; return value; }; const hostSummaries = createMemo(() => { return sortedHosts().map((host) => { const totalContainers = host.containers?.length ?? 0; const runningContainers = host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0; const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0; const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0); const memoryUsed = host.memory?.used ?? 0; const memoryTotal = host.memory?.total ?? host.totalMemoryBytes ?? 0; const memoryPercent = host.memory?.usage ? clampPercent(host.memory.usage) : memoryTotal > 0 ? clampPercent((memoryUsed / memoryTotal) * 100) : 0; const memoryLabel = memoryTotal > 0 ? `${formatBytes(memoryUsed, 0)} / ${formatBytes(memoryTotal, 0)}` : undefined; let diskPercent = 0; let diskLabel: string | undefined; if (host.disks && host.disks.length > 0) { const totals = host.disks.reduce( (acc, disk) => { acc.used += disk.used ?? 0; acc.total += disk.total ?? 0; return acc; }, { used: 0, total: 0 }, ); if (totals.total > 0) { diskPercent = clampPercent((totals.used / totals.total) * 100); diskLabel = `${formatBytes(totals.used, 0)} / ${formatBytes(totals.total, 0)}`; } } const uptimeSeconds = host.uptimeSeconds ?? 0; const lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'; const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : ''; return { host, cpuPercent, memoryPercent, memoryLabel, diskPercent, diskLabel, runningPercent, runningCount: runningContainers, totalCount: totalContainers, uptimeSeconds, lastSeenRelative, lastSeenAbsolute, }; }); }); let searchInputRef: HTMLInputElement | undefined; const focusSearchInput = () => { queueMicrotask(() => searchInputRef?.focus()); }; const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } if (event.ctrlKey || event.metaKey || event.altKey) { return; } if (event.key.length === 1 && searchInputRef) { event.preventDefault(); focusSearchInput(); setSearch((prev) => prev + event.key); } }; onMount(() => { document.addEventListener('keydown', handleKeyDown); // Load docker metadata from API DockerMetadataAPI.getAllMetadata() .then((metadata) => { setDockerMetadata(metadata || {}); try { localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(metadata || {})); } catch (err) { logger.warn('Failed to cache docker metadata', err); } }) .catch((err) => { logger.debug('Failed to load docker metadata', err); }); }); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); // Handler to update docker resource custom URL const handleCustomUrlUpdate = (resourceId: string, url: string) => { const trimmedUrl = url.trim(); const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl; setDockerMetadata((prev) => { const updated = { ...prev }; if (nextUrl === undefined) { delete updated[resourceId]; } else { updated[resourceId] = { ...(prev[resourceId] || { id: resourceId }), customUrl: nextUrl, }; } // Cache to localStorage try { localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(updated)); } catch (err) { logger.warn('Failed to cache docker metadata', err); } return updated; }); }; createEffect(() => { const hostId = selectedHostId(); if (!hostId) { return; } if (!sortedHosts().some((host) => host.id === hostId)) { setSelectedHostId(null); } }); const hostMatchesStatus = (host: DockerHost) => { const status = statusFilter(); if (status === 'all') return true; const normalized = host.status?.toLowerCase() ?? ''; if (status === 'online') return normalized === 'online'; if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized); if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized); return true; }; const filteredHostSummaries = createMemo(() => { const summaries = hostSummaries(); if (statusFilter() === 'all') return summaries; return summaries.filter((summary) => hostMatchesStatus(summary.host)); }); createEffect(() => { const hostId = selectedHostId(); if (!hostId) return; if (!filteredHostSummaries().some((summary) => summary.host.id === hostId)) { setSelectedHostId(null); } }); const statsFilter = createMemo(() => { const status = statusFilter(); if (status === 'all') return null; return { type: 'host-status' as const, value: status }; }); const handleHostSelect = (hostId: string) => { setSelectedHostId((current) => (current === hostId ? null : hostId)); }; const renderFilter = () => ( { setSearch(''); setSelectedHostId(null); setStatusFilter('all'); }} searchInputRef={(el) => { searchInputRef = el; }} /> ); return (
} title={reconnecting() ? 'Reconnecting to container agents...' : 'Loading container data...'} description={ reconnecting() ? 'Re-establishing metrics from the monitoring service.' : connected() ? 'Waiting for the first container update.' : 'Connecting to the monitoring service.' } /> 0} fallback={ <> {renderFilter()} } title="No container runtimes reporting" description="Deploy the Pulse container agent (Docker or Podman) on at least one host to light up this tab. As soon as an agent reports in, runtime metrics appear automatically." actions={ } /> } > 0}> {renderFilter()}
); };