diff --git a/frontend-modern/src/components/Dashboard/MetricBar.tsx b/frontend-modern/src/components/Dashboard/MetricBar.tsx index f085566..e7ef1f4 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.tsx @@ -45,12 +45,9 @@ export function MetricBar(props: MetricBarProps) { }); return ( -
-
-
+
+
+
{props.label} diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index ef35be7..94d32bb 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -3,7 +3,7 @@ import type { DockerHost } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { renderDockerStatusBadge } from './DockerStatusBadge'; -import { formatUptime } from '@/utils/format'; +import { formatPercent, formatUptime } from '@/utils/format'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; export interface DockerHostSummary { @@ -11,6 +11,8 @@ export interface DockerHostSummary { cpuPercent: number; memoryPercent: number; memoryLabel?: string; + diskPercent: number; + diskLabel?: string; runningPercent: number; runningCount: number; totalCount: number; @@ -25,7 +27,7 @@ interface DockerHostSummaryTableProps { onSelect: (hostId: string) => void; } -type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'running' | 'lastSeen' | 'agent'; +type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent'; type SortDirection = 'asc' | 'desc'; @@ -52,6 +54,19 @@ export const DockerHostSummaryTable: Component = (p return lastSeen; }; + const runningStatusClass = (summary: DockerHostSummary) => { + if (!summary.totalCount || summary.totalCount <= 0) { + return 'text-gray-400 dark:text-gray-500'; + } + if (summary.runningPercent >= 99) { + return 'text-green-600 dark:text-green-400'; + } + if (summary.runningPercent >= 70) { + return 'text-yellow-600 dark:text-yellow-400'; + } + return 'text-red-600 dark:text-red-400'; + }; + const sortedSummaries = createMemo(() => { const list = [...props.summaries()]; const key = sortKey(); @@ -75,6 +90,9 @@ export const DockerHostSummaryTable: Component = (p case 'memory': value = a.memoryPercent - b.memoryPercent; break; + case 'disk': + value = a.diskPercent - b.diskPercent; + break; case 'running': value = a.runningPercent - b.runningPercent; break; @@ -129,21 +147,27 @@ export const DockerHostSummaryTable: Component = (p > Host {renderSortIndicator('name')} - + Status handleSort('cpu')} > CPU {renderSortIndicator('cpu')} handleSort('memory')} > Memory {renderSortIndicator('memory')} + handleSort('disk')} + > + Disk {renderSortIndicator('disk')} + handleSort('running')} @@ -151,19 +175,19 @@ export const DockerHostSummaryTable: Component = (p Containers {renderSortIndicator('running')} handleSort('uptime')} > Uptime {renderSortIndicator('uptime')} handleSort('lastSeen')} > Last Update {renderSortIndicator('lastSeen')} handleSort('agent')} > Agent {renderSortIndicator('agent')} @@ -176,7 +200,7 @@ export const DockerHostSummaryTable: Component = (p const selected = props.selectedHostId() === summary.host.id; const online = isHostOnline(summary.host); const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'; - const agentLabel = summary.host.agentVersion ? `v${summary.host.agentVersion}` : '—'; + const agentLabel = summary.host.agentVersion ? summary.host.agentVersion : '—'; const rowStyle = () => { const styles: Record = {}; @@ -217,7 +241,7 @@ export const DockerHostSummaryTable: Component = (p style={rowStyle()} onClick={() => props.onSelect(summary.host.id)} > - +
{summary.host.displayName} @@ -274,80 +298,107 @@ export const DockerHostSummaryTable: Component = (p
- - {renderDockerStatusBadge(summary.host.status)} - - -
}> - - - - -
}> - - - - -
- + +
+ {renderDockerStatusBadge(summary.host.status)}
- - - {uptimeLabel} - - - -
- {summary.lastSeenRelative} + +
+ —}> + +
- -
- {summary.lastSeenAbsolute} -
-
- -
- -
+ +
+ —}> + + +
+ + +
+ —}> + + +
+ + +
+ 0} + fallback={} + > + + {summary.runningCount}/{summary.totalCount} + + +
+ + +
+ + {uptimeLabel} + +
+ + +
+ —} + > + {(relative) => ( + + {relative()} + + )} + +
+ + +
+ —} + > + {(version) => ( - v{summary.host.agentVersion} + v{version()} - - - ⚠ Update available - - -
+ )} - - - {summary.host.intervalSeconds}s + + + ⚠
diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index ede9741..459e07e 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -1,22 +1,21 @@ import type { Component } from 'solid-js'; -import { Show, createMemo, createSignal, onMount, onCleanup } 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 { DockerSummaryStatsBar } from './DockerSummaryStats'; +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'; interface DockerHostsProps { hosts: DockerHost[]; activeAlerts?: Record | any; } -type StatsFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; - export const DockerHosts: Component = (props) => { const navigate = useNavigate(); const { initialDataReceived, reconnecting, connected } = useWebSocket(); @@ -40,8 +39,72 @@ export const DockerHosts: Component = (props) => { const [search, setSearch] = createSignal(''); const debouncedSearch = useDebouncedValue(search, 250); + const [selectedHostId, setSelectedHostId] = createSignal(null); - const [statsFilter, setStatsFilter] = createSignal(null); + 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)} / ${formatBytes(memoryTotal)}` : 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)} / ${formatBytes(totals.total)}`; + } + } + + 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; @@ -52,12 +115,6 @@ export const DockerHosts: Component = (props) => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement; - if (event.key === 'Escape' && statsFilter()) { - event.preventDefault(); - setStatsFilter(null); - return; - } - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } @@ -76,18 +133,18 @@ export const DockerHosts: Component = (props) => { onMount(() => document.addEventListener('keydown', handleKeyDown)); onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); - const handleStatsFilterChange = (filter: StatsFilter) => { - if (!filter) { - setStatsFilter(null); + createEffect(() => { + const hostId = selectedHostId(); + if (!hostId) { return; } + if (!sortedHosts().some((host) => host.id === hostId)) { + setSelectedHostId(null); + } + }); - setStatsFilter((current) => { - if (current && current.type === filter.type && current.value === filter.value) { - return null; - } - return filter; - }); + const handleHostSelect = (hostId: string) => { + setSelectedHostId((current) => (current === hostId ? null : hostId)); }; return ( @@ -127,25 +184,25 @@ export const DockerHosts: Component = (props) => { setSearch={setSearch} onReset={() => { setSearch(''); - setStatsFilter(null); + setSelectedHostId(null); }} searchInputRef={(el) => { searchInputRef = el; }} /> - - 0}> + - +
} diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 248b98d..9591c03 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1,21 +1,103 @@ -import { Component, For, Show, batch, createSignal, createMemo, createEffect, onCleanup } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; +import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; -import { - DockerTree, - type DockerTreeHostEntry, - type DockerTreeSelection, - type DockerTreeServiceEntry, -} from './DockerTree'; +import { formatBytes, formatPercent, formatUptime, formatRelativeTime } from '@/utils/format'; + +const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); +const DEGRADED_HOST_STATUSES = new Set([ + 'degraded', + 'warning', + 'maintenance', + 'partial', + 'initializing', + 'unknown', +]); + +const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); +const ERROR_CONTAINER_STATES = new Set([ + 'restarting', + 'dead', + 'removing', + 'failed', + 'error', + 'oomkilled', + 'unhealthy', +]); + +const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { + switch (type) { + case 'container': + return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'; + case 'service': + return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-200'; + case 'task': + return 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-200'; + default: + return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; + } +}; + +type StatsFilter = + | { type: 'host-status'; value: string } + | { type: 'container-state'; value: string } + | { type: 'service-health'; value: string } + | null; + +type SearchToken = { key?: string; value: string }; + +type DockerRow = + | { + kind: 'container'; + id: string; + host: DockerHost; + container: DockerContainer; + } + | { + kind: 'service'; + id: string; + host: DockerHost; + service: DockerService; + tasks: DockerTask[]; + }; interface DockerUnifiedTableProps { hosts: DockerHost[]; searchTerm?: string; - statsFilter?: { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; + statsFilter?: StatsFilter; + selectedHostId?: () => string | null; } +const rowExpandState = new Map(); + +const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; + +const ensureMs = (value?: number | string | null): number | null => { + if (!value) return null; + if (typeof value === 'number') { + return value > 1e12 ? value : value * 1000; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +const parseSearchTerm = (term?: string): SearchToken[] => { + if (!term) return []; + return term + .trim() + .split(/\s+/) + .filter(Boolean) + .map((token) => { + const [rawKey, ...rest] = token.split(':'); + if (rest.length === 0) { + return { value: token.toLowerCase() }; + } + return { key: rawKey.toLowerCase(), value: rest.join(':').toLowerCase() }; + }); +}; + const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => { if (!containers.length) return undefined; @@ -27,10 +109,7 @@ const findContainerForTask = (containers: DockerContainer[], task: DockerTask) = const id = container.id?.toLowerCase() ?? ''; const name = container.name?.toLowerCase() ?? ''; - const idMatch = - !!taskId && - (id === taskId || id.includes(taskId) || taskId.includes(id)); - + const idMatch = !!taskId && (id === taskId || id.includes(taskId) || taskId.includes(id)); const nameMatch = !!taskName && (name === taskName || @@ -42,317 +121,632 @@ const findContainerForTask = (containers: DockerContainer[], task: DockerTask) = }); }; -const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); -const DEGRADED_HOST_STATUSES = new Set(['degraded', 'warning', 'maintenance', 'partial', 'initializing', 'unknown']); - -const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); -const ERROR_CONTAINER_STATES = new Set(['restarting', 'dead', 'removing', 'failed', 'error', 'oomkilled', 'unhealthy']); - -// Persistent state for expanded hosts and services -const hostExpandState = new Map(); -const hostExpandSignals = new Map>>(); -const serviceExpandState = new Map(); -const serviceExpandSignals = new Map>>(); - -const getTaskNodeId = (serviceKey: string, task: DockerTask, index: number) => { - if (task.id) { - return `${serviceKey}:task:${task.id}`; +const hostMatchesFilter = (filter: StatsFilter, host: DockerHost) => { + if (!filter || filter.type !== 'host-status') return true; + const status = toLower(host.status); + if (filter.value === 'offline') { + return OFFLINE_HOST_STATUSES.has(status); } - if (task.containerId) { - return `${serviceKey}:task:${task.containerId}`; + if (filter.value === 'degraded') { + return DEGRADED_HOST_STATUSES.has(status) || status === 'degraded'; } - if (task.containerName) { - return `${serviceKey}:task:${task.containerName}`; + if (filter.value === 'online') { + return status === 'online'; } - if (task.slot !== undefined && task.slot !== null) { - return `${serviceKey}:task:slot-${task.slot}`; - } - return `${serviceKey}:task:${index}`; + return true; }; -// Docker Host Group Header Component (matches NodeGroupHeader style) -interface DockerHostHeaderProps { - host: DockerHost; - colspan: number; - isExpanded: boolean; - onToggle: () => void; - isActive?: boolean; -} +const containerMatchesStateFilter = (filter: StatsFilter, container: DockerContainer) => { + if (!filter || filter.type !== 'container-state') return true; + const state = toLower(container.state); + if (filter.value === 'running') return state === 'running'; + if (filter.value === 'stopped') return STOPPED_CONTAINER_STATES.has(state); + if (filter.value === 'error') { + return ERROR_CONTAINER_STATES.has(state) || toLower(container.health) === 'unhealthy'; + } + return true; +}; -const DockerHostHeader: Component = (props) => { - const status = () => props.host.status?.toLowerCase() ?? 'unknown'; - const isOnline = () => status() === 'online'; - const isOffline = () => OFFLINE_HOST_STATUSES.has(status()); - const displayName = () => props.host.displayName || props.host.hostname || props.host.id; +const serviceMatchesHealthFilter = (filter: StatsFilter, service: DockerService) => { + if (!filter || filter.type !== 'service-health') return true; + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + if (filter.value === 'degraded') { + return desired > 0 && running < desired; + } + if (filter.value === 'healthy') { + return desired > 0 && running >= desired; + } + return true; +}; - const totalContainers = () => (props.host.containers?.length || 0); - const runningContainers = () => - (props.host.containers?.filter((c) => c.state?.toLowerCase() === 'running').length || 0); - const totalServices = () => (props.host.services?.length || 0); +const containerMatchesToken = ( + token: SearchToken, + host: DockerHost, + container: DockerContainer, +) => { + const state = toLower(container.state); + const health = toLower(container.health); + const hostName = toLower(host.displayName ?? host.hostname ?? host.id); + + if (token.key === 'name') { + return ( + toLower(container.name).includes(token.value) || + toLower(container.id).includes(token.value) + ); + } + + if (token.key === 'image') { + return toLower(container.image).includes(token.value); + } + + if (token.key === 'host') { + return hostName.includes(token.value); + } + + if (token.key === 'state') { + return state.includes(token.value) || health.includes(token.value); + } + + const fields: string[] = [ + container.name, + container.id, + container.image, + container.status, + container.state, + container.health, + host.displayName, + host.hostname, + host.id, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + if (container.labels) { + Object.entries(container.labels).forEach(([key, value]) => { + fields.push(key.toLowerCase()); + if (value) fields.push(value.toLowerCase()); + }); + } + + if (container.ports) { + container.ports.forEach((port) => { + const parts = [port.privatePort, port.publicPort, port.protocol, port.ip] + .filter(Boolean) + .map(String) + .join(':') + .toLowerCase(); + if (parts) fields.push(parts); + }); + } + + return fields.some((field) => field.includes(token.value)); +}; + +const serviceMatchesToken = (token: SearchToken, host: DockerHost, service: DockerService) => { + const hostName = toLower(host.displayName ?? host.hostname ?? host.id); + const serviceName = toLower(service.name ?? service.id); + const image = toLower(service.image); + + if (token.key === 'name') { + return serviceName.includes(token.value); + } + + if (token.key === 'image') { + return image.includes(token.value); + } + + if (token.key === 'host') { + return hostName.includes(token.value); + } + + if (token.key === 'state') { + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + const status = desired > 0 && running >= desired ? 'healthy' : 'degraded'; + return status.includes(token.value); + } + + const fields: string[] = [ + service.name, + service.id, + service.image, + service.stack, + service.mode, + host.displayName, + host.hostname, + host.id, + ] + .filter(Boolean) + .map((value) => value!.toLowerCase()); + + if (service.labels) { + Object.entries(service.labels).forEach(([key, value]) => { + fields.push(key.toLowerCase()); + if (value) fields.push(value.toLowerCase()); + }); + } + + return fields.some((field) => field.includes(token.value)); +}; + +const statusDotClass = (state: string) => { + switch (state) { + case 'running': + case 'healthy': + return 'bg-green-500'; + case 'paused': + case 'created': + return 'bg-amber-500'; + case 'stopped': + case 'exited': + return 'bg-gray-400'; + default: + return ERROR_CONTAINER_STATES.has(state) ? 'bg-red-500' : 'bg-amber-500'; + } +}; + +const hostStatusBadge = (host: DockerHost) => { + const status = toLower(host.status); + if (status === 'online') { + return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; + } + if (OFFLINE_HOST_STATUSES.has(status)) { + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; + } + return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; +}; + +const serviceHealthBadge = (service: DockerService) => { + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + if (desired === 0) { + return { + label: 'No tasks', + class: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300', + }; + } + if (running >= desired) { + return { + label: 'Healthy', + class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + }; + } + return { + label: `Degraded (${running}/${desired})`, + class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + }; +}; + +const buildRowId = (host: DockerHost, row: DockerRow) => { + if (row.kind === 'container') { + return `container:${host.id}:${row.container.id ?? row.container.name}`; + } + return `service:${host.id}:${row.service.id ?? row.service.name}`; +}; + +const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => { + const status = toLower(props.host.status); + const lastSeen = ensureMs(props.host.lastSeen); + const uptime = props.host.uptimeSeconds ?? props.host.intervalSeconds; + const displayName = props.host.displayName || props.host.hostname || props.host.id; return ( - - + + + Uptime {formatUptime(uptime!)} + + + + + Last seen {formatRelativeTime(lastSeen!)} + + +
); }; -// Service Row Component (expandable for task containers) -interface ServiceRowProps { - service: DockerService; - hostId: string; - tasks: DockerTreeServiceEntry['tasks']; - containers: DockerContainer[]; - isExpanded: boolean; - onToggle: () => void; - isSelected?: boolean; - rowRef?: (row: HTMLTableRowElement | null) => void; - selectedTaskId?: string | null; - onTaskMount?: (taskNodeId: string, row: HTMLTableRowElement) => void; - onTaskUnmount?: (taskNodeId: string) => void; -} - -const ServiceRow: Component = (props) => { - const desiredTasks = () => props.service.desiredTasks ?? 0; - const runningTasks = () => props.service.runningTasks ?? 0; - const isHealthy = () => desiredTasks() > 0 && runningTasks() >= desiredTasks(); - const hasTasks = () => props.tasks.length > 0; - - onCleanup(() => { - props.rowRef?.(null); +const DockerContainerRow: Component<{ row: Extract; columns: number }> = (props) => { + const { host, container } = props.row; + const rowId = buildRowId(host, props.row); + const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false); + const hasDrawerContent = createMemo(() => { + return ( + (container.ports && container.ports.length > 0) || + (container.labels && Object.keys(container.labels).length > 0) || + (container.networks && container.networks.length > 0) + ); }); - const healthBadge = () => { - if (desiredTasks() === 0) { - return ( - - No tasks - - ); + const toggle = (event: MouseEvent) => { + if (!hasDrawerContent()) return; + const target = event.target as HTMLElement; + if (target.closest('a, button, [data-prevent-toggle]')) return; + setExpanded((prev) => { + const next = !prev; + rowExpandState.set(rowId, next); + return next; + }); + }; + + const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); + const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0)); + const memUsageLabel = () => { + if (!container.memoryUsageBytes) return undefined; + const used = formatBytes(container.memoryUsageBytes); + const limit = container.memoryLimitBytes + ? formatBytes(container.memoryLimitBytes) + : undefined; + return limit ? `${used} / ${limit}` : used; + }; + + const uptime = () => (container.uptimeSeconds ? formatUptime(container.uptimeSeconds) : '—'); + const restarts = () => container.restartCount ?? 0; + + const state = () => toLower(container.state); + const health = () => toLower(container.health); + + const statusBadgeClass = () => { + if (state() === 'running' && (!health() || health() === 'healthy')) { + return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; } - if (isHealthy()) { - return ( - - Healthy - - ); + if (ERROR_CONTAINER_STATES.has(state()) || health() === 'unhealthy') { + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; } - return ( - - Degraded ({runningTasks()}/{desiredTasks()}) - - ); + if (STOPPED_CONTAINER_STATES.has(state())) { + return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; + } + return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; + }; + + const statusLabel = () => { + if (health()) { + return `${container.state ?? 'Unknown'} (${container.health})`; + } + return container.status || container.state || 'Unknown'; }; return ( <> row && props.rowRef?.(row)} - class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ - props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' - }`} + class={`border-b border-gray-200 dark:border-gray-700 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'}`} + onClick={toggle} + aria-expanded={expanded()} > - - - - -
- {props.service.image || 'Image not specified'} -
- -
- {props.service.mode} + +
+
+ + + + Container + + + + + {container.image || '—'} + + + + + {statusLabel()} + + + + 0} + fallback={} + > + - {healthBadge()} + + 0} + fallback={} + > + + + + + {restarts()} + restarts + + {uptime()} - {/* Task Containers Drawer */} - + + + +
+ 0}> +
+
+ Ports +
+
+ {container.ports!.map((port) => { + const label = port.publicPort + ? `${port.publicPort}:${port.privatePort}/${port.protocol}` + : `${port.privatePort}/${port.protocol}`; + return ( + + {label} + + ); + })} +
+
+
+ + 0}> +
+
+ Networks +
+
+ {container.networks!.map((network) => ( +
+ {network.name} + + IPv4: {network.ipv4} + + + IPv6: {network.ipv6} + +
+ ))} +
+
+
+ + 0}> +
+
+ Labels +
+
+ {Object.entries(container.labels!).map(([key, value]) => ( + + {key} + : {value} + + ))} +
+
+
+
+ + +
+ + ); +}; + +const DockerServiceRow: Component<{ row: Extract; columns: number }> = (props) => { + const { host, service, tasks } = props.row; + const rowId = buildRowId(host, props.row); + const [expanded, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false); + const hasTasks = () => tasks.length > 0; + + const toggle = (event: MouseEvent) => { + if (!hasTasks()) return; + const target = event.target as HTMLElement; + if (target.closest('a, button, [data-prevent-toggle]')) return; + setExpanded((prev) => { + const next = !prev; + rowExpandState.set(rowId, next); + return next; + }); + }; + + const badge = serviceHealthBadge(service); + const updatedAt = ensureMs(service.updatedAt ?? service.createdAt); + + return ( + <> + + +
+
+ + + + Service + + + + + {service.image || '—'} + + + + + {badge.label} + + + — + — + + + {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} + + tasks + + + + {(timestamp) => ( + + {formatRelativeTime(timestamp())} + + )} + + + + + - -
-

- Task Containers ({props.tasks.length}) -

-
- - - - - - - - - - - - - - {(taskEntry) => { - const task = taskEntry.task; - const currentState = task.currentState?.toLowerCase() || 'unknown'; - const stateBadge = () => { - if (currentState === 'running') { +
Task/ContainerNodeStateCPUMemoryStarted
+
+
+ Tasks + + {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'} + +
+
+ + + + + + + + + + + + + + {(task) => { + const container = findContainerForTask(host.containers || [], task); + const cpu = container?.cpuPercent ?? 0; + const mem = container?.memoryPercent ?? 0; + const updated = ensureMs(task.updatedAt ?? task.createdAt ?? task.startedAt); + const taskLabel = () => { + if (task.containerName) return task.containerName; + if (task.containerId) return task.containerId.slice(0, 12); + if (task.slot !== undefined) return `slot-${task.slot}`; + return task.id ?? 'Task'; + }; + const state = toLower(task.currentState ?? task.desiredState ?? 'unknown'); + const stateClass = () => { + if (state === 'running') { return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; } - if (currentState === 'failed') { + if (state === 'failed' || state === 'error') { return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; } - return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; }; - - // Find the corresponding container for this task - const container = findContainerForTask(props.containers, task); - - const hasCpuData = () => - typeof container?.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent); - const hasMemData = () => - typeof container?.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent); - const cpuPercent = () => (hasCpuData() ? container!.cpuPercent : 0); - const memPercent = () => (hasMemData() ? container!.memoryPercent : 0); - - const taskLabel = () => { - const name = task.containerName || task.containerId?.slice(0, 12) || '—'; - if (task.slot !== undefined && task.slot !== null) { - return `${name}.${task.slot}`; - } - return name; - }; - - onCleanup(() => { - props.onTaskUnmount?.(taskEntry.nodeId); - }); - return ( - row && props.onTaskMount?.(taskEntry.nodeId, row)} - class={`hover:bg-gray-100 dark:hover:bg-gray-800/70 ${ - props.selectedTaskId === taskEntry.nodeId ? 'bg-sky-50 dark:bg-sky-900/30' : '' - }`} - > - + - - - - - ); @@ -369,872 +763,213 @@ const ServiceRow: Component = (props) => { ); }; -// Container Row Component -interface ContainerRowProps { - container: DockerContainer; - indent?: boolean; - isSelected?: boolean; - rowRef?: (row: HTMLTableRowElement | null) => void; -} +const DockerUnifiedTable: Component = (props) => { + const tokens = createMemo(() => parseSearchTerm(props.searchTerm)); -const ContainerRow: Component = (props) => { - const formatPorts = () => { - if (!props.container.ports || props.container.ports.length === 0) return '—'; - return props.container.ports - .map((p) => { - if (p.publicPort) { - return `${p.publicPort}:${p.privatePort}/${p.protocol}`; - } - return `${p.privatePort}/${p.protocol}`; - }) - .join(', '); - }; - - const stateBadge = () => { - const state = props.container.state?.toLowerCase() || 'unknown'; - if (state === 'running') { - return ( - - Running - - ); - } - if (state === 'exited' || state === 'stopped') { - return ( - - Stopped - - ); - } - return ( - - {state} - - ); - }; - - const hasCpuData = () => - typeof props.container.cpuPercent === 'number' && !Number.isNaN(props.container.cpuPercent); - const hasMemData = () => - typeof props.container.memoryPercent === 'number' && !Number.isNaN(props.container.memoryPercent); - const cpuPercent = () => (hasCpuData() ? props.container.cpuPercent! : 0); - const memPercent = () => (hasMemData() ? props.container.memoryPercent! : 0); - - onCleanup(() => { - props.rowRef?.(null); - }); - - return ( - row && props.rowRef?.(row)} - class={`border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ - props.isSelected ? 'bg-sky-50 dark:bg-sky-900/30' : '' - }`} - > - - - - - - - - ); -}; - -export const DockerUnifiedTable: Component = (props) => { - // Track expanded state for each host - const getHostExpandState = (hostId: string) => { - if (!hostExpandState.has(hostId)) { - hostExpandState.set(hostId, true); - } - if (!hostExpandSignals.has(hostId)) { - hostExpandSignals.set( - hostId, - createSignal(hostExpandState.get(hostId) ?? true), - ); - } - - const [isExpanded, setIsExpanded] = hostExpandSignals.get(hostId)!; - - const setExpanded = (value: boolean) => - setIsExpanded(() => { - hostExpandState.set(hostId, value); - return value; - }); - - return { - isExpanded, - toggle: () => - setIsExpanded((prev) => { - const next = !prev; - hostExpandState.set(hostId, next); - return next; - }), - setExpanded, - }; - }; - - // Track expanded state for each service - const getServiceExpandState = (serviceKey: string) => { - if (!serviceExpandSignals.has(serviceKey)) { - serviceExpandSignals.set( - serviceKey, - createSignal(serviceExpandState.get(serviceKey) ?? false), - ); - } - - const [isExpanded, setIsExpanded] = serviceExpandSignals.get(serviceKey)!; - - const setExpanded = (value: boolean) => - setIsExpanded(() => { - serviceExpandState.set(serviceKey, value); - return value; - }); - - return { - isExpanded, - toggle: () => - setIsExpanded((prev) => { - const next = !prev; - serviceExpandState.set(serviceKey, next); - return next; - }), - setExpanded, - }; - }; - - const normalizeContainerState = (state?: string | null, status?: string | null) => { - const lowerState = state?.toLowerCase().trim(); - if (lowerState) return lowerState; - const lowerStatus = status?.toLowerCase().trim(); - if (!lowerStatus) return ''; - if (lowerStatus.startsWith('up')) return 'running'; - if (lowerStatus.startsWith('exited')) return 'exited'; - if (lowerStatus.startsWith('created')) return 'created'; - if (lowerStatus.startsWith('paused')) return 'paused'; - if (lowerStatus.includes('restarting')) return 'restarting'; - if (lowerStatus.includes('unhealthy')) return 'unhealthy'; - if (lowerStatus.includes('dead')) return 'dead'; - if (lowerStatus.includes('removing')) return 'removing'; - return lowerStatus; - }; - - const matchesContainerStateFilter = (filterValue: string, container?: DockerContainer | null) => { - if (!container) return false; - const state = normalizeContainerState(container.state, container.status); - if (filterValue === 'running') { - return state === 'running'; - } - if (filterValue === 'stopped') { - return STOPPED_CONTAINER_STATES.has(state); - } - if (filterValue === 'error') { - return ERROR_CONTAINER_STATES.has(state); - } - return true; - }; - - const matchesTaskStateFilter = (filterValue: string, task: DockerTask, container?: DockerContainer | null) => { - if (!filterValue) return true; - if (container && matchesContainerStateFilter(filterValue, container)) { - return true; - } - - const current = task.currentState?.toLowerCase() ?? ''; - if (filterValue === 'running') { - return current === 'running'; - } - if (filterValue === 'stopped') { - return current === 'complete' || current === 'shutdown' || current === 'stopped'; - } - if (filterValue === 'error') { - return current === 'failed' || current === 'error'; - } - return true; - }; - - const hostMatchesFilter = (host: DockerHost) => { - const filter = props.statsFilter; - if (!filter || filter.type !== 'host-status') { - return true; - } - const status = host.status?.toLowerCase() ?? 'unknown'; - switch (filter.value) { - case 'offline': - return OFFLINE_HOST_STATUSES.has(status); - case 'degraded': - return DEGRADED_HOST_STATUSES.has(status); - case 'online': - return status === 'online'; - default: - return true; - } - }; - - // Parse search terms - const searchTerms = createMemo(() => { - const term = props.searchTerm || ''; - return term - .toLowerCase() - .split(/[\s,]+/) - .map((t) => t.trim()) - .filter(Boolean); - }); - - // Check if a container matches search terms - const containerMatchesSearch = (host: DockerHost, container: DockerContainer) => { - const terms = searchTerms(); - if (terms.length === 0) return true; - - return terms.every((term) => { - const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; - const target = value || term; - - const tokens = [ - container.name, - container.image, - container.id, - container.state, - container.status, - host.displayName, - host.hostname, - ]; - - const hasToken = (list: (string | undefined)[]) => - list - .filter(Boolean) - .some((entry) => entry!.toLowerCase().includes(target)); - - if (prefix) { - switch (prefix) { - case 'host': - return hasToken([host.displayName, host.hostname]); - case 'name': - return hasToken([container.name]); - case 'image': - return hasToken([container.image]); - case 'state': - return hasToken([container.state, container.status]); - case 'id': - return hasToken([container.id]); - default: - return hasToken(tokens); - } - } - - return hasToken(tokens); - }); - }; - - // Check if a service matches search terms - const serviceMatchesSearch = (host: DockerHost, service: DockerService) => { - const terms = searchTerms(); - if (terms.length === 0) return true; - - return terms.every((term) => { - const [prefix, value] = term.includes(':') ? term.split(/:(.+)/) : [null, term]; - const target = value || term; - - const tokens = [ - service.name, - service.id, - service.image, - host.displayName, - host.hostname, - ]; - - const hasToken = (list: (string | undefined)[]) => - list - .filter(Boolean) - .some((entry) => entry!.toLowerCase().includes(target)); - - if (prefix) { - switch (prefix) { - case 'host': - return hasToken([host.displayName, host.hostname]); - case 'name': - case 'service': - return hasToken([service.name, service.id]); - case 'image': - return hasToken([service.image]); - default: - return hasToken(tokens); - } - } - - return hasToken(tokens); - }); - }; - - // Sort hosts alphabetically const sortedHosts = createMemo(() => { const hosts = props.hosts || []; return [...hosts].sort((a, b) => { - const aName = a.displayName || a.hostname || a.id || ''; - const bName = b.displayName || b.hostname || b.id || ''; + const aName = a.displayName || a.hostname || a.id; + const bName = b.displayName || b.hostname || b.id; return aName.localeCompare(bName); }); }); - const containerFilterValue = () => - props.statsFilter?.type === 'container-state' ? props.statsFilter.value : null; + const groupedRows = createMemo(() => { + const groups: Array<{ host: DockerHost; rows: DockerRow[] }> = []; + const filter = props.statsFilter ?? null; + const searchTokens = tokens(); + const selectedHostId = props.selectedHostId ? props.selectedHostId() : null; -const visibleHosts = createMemo(() => { - const results: DockerTreeHostEntry[] = []; - const hosts = sortedHosts(); - const filterValue = containerFilterValue(); - - hosts.forEach((host, index) => { - if (!hostMatchesFilter(host)) { + sortedHosts().forEach((host) => { + if (!hostMatchesFilter(filter, host)) { return; } - const hostId = - host.id || - host.hostname || - host.displayName || - `host-${index}`; + if (selectedHostId && host.id !== selectedHostId) { + return; + } - const hostContainers = host.containers || []; - const hostTasks = host.tasks || []; + const hostRows: DockerRow[] = []; + const containers = host.containers || []; + const services = host.services || []; + const tasks = host.tasks || []; - const referencedContainers = new Set(); - hostTasks.forEach((task) => { - if (task.containerId) referencedContainers.add(task.containerId); - if (task.containerName) referencedContainers.add(task.containerName); - }); + containers.forEach((container) => { + if (!containerMatchesStateFilter(filter, container)) return; + const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container)); + if (!matchesSearch) return; - const standalone = hostContainers - .filter((container) => { - const id = container.id || ''; - const name = container.name || ''; - - if (referencedContainers.has(id) || referencedContainers.has(name)) { - return false; - } - - if (!containerMatchesSearch(host, container)) { - return false; - } - - if (filterValue && !matchesContainerStateFilter(filterValue, container)) { - return false; - } - - return true; - }) - .map((container, idx) => ({ + hostRows.push({ + kind: 'container', + id: container.id || `${host.id}-container-${container.name}`, + host, container, - nodeId: container.id - ? `${hostId}:container:${container.id}` - : container.name - ? `${hostId}:container:${container.name}` - : `${hostId}:container:${idx}`, - })); + }); + }); - const services = (host.services || []).reduce((rows, service) => { - if (!serviceMatchesSearch(host, service)) { - return rows; - } + services.forEach((service) => { + if (!serviceMatchesHealthFilter(filter, service)) return; + const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service)); + if (!matchesSearch) return; - const filteredTasks = hostTasks.filter((task) => { - const matchesService = - (task.serviceId && task.serviceId === service.id) || - (!task.serviceId && task.serviceName && task.serviceName === service.name); - if (!matchesService) return false; - - if (!filterValue) return true; - const container = findContainerForTask(hostContainers, task); - return matchesTaskStateFilter(filterValue, task, container); + const associatedTasks = tasks.filter((task) => { + if (service.id && task.serviceId) { + return task.serviceId === service.id; + } + if (service.name && task.serviceName) { + return task.serviceName === service.name; + } + return false; }); - if (filterValue && filteredTasks.length === 0) { - return rows; - } - - const identifier = service.id || service.name || 'service'; - const serviceKey = `${hostId}:${identifier}`; - - const tasks = filteredTasks.map((task, idx) => ({ - task, - nodeId: getTaskNodeId(serviceKey, task, idx), - })); - - rows.push({ - key: serviceKey, + hostRows.push({ + kind: 'service', + id: service.id || `${host.id}-service-${service.name}`, + host, service, - tasks, + tasks: associatedTasks, }); - - return rows; - }, []); - - if (services.length === 0 && standalone.length === 0) { - return; - } - - results.push({ - host, - hostId, - containers: hostContainers, - services, - standaloneContainers: standalone, }); + + if (hostRows.length > 0) { + hostRows.sort((a, b) => { + const nameA = + a.kind === 'container' + ? a.container.name || a.container.id || '' + : a.service.name || a.service.id || ''; + const nameB = + b.kind === 'container' + ? b.container.name || b.container.id || '' + : b.service.name || b.service.id || ''; + return nameA.localeCompare(nameB); + }); + groups.push({ host, rows: hostRows }); + } }); - return results; -}); - - const [selectedNode, setSelectedNode] = createSignal(null); - const [isMobileTreeOpen, setIsMobileTreeOpen] = createSignal(false); - - const displayedHosts = createMemo(() => { - const hosts = visibleHosts(); - const selection = selectedNode(); - if (!selection) return hosts; - if (selection.type === 'host') { - const match = hosts.find((host) => host.hostId === selection.hostId); - return match ? [match] : hosts; - } - return hosts.filter((host) => host.hostId === selection.hostId); + return groups; }); - const hostRefs = new Map(); - const serviceRefs = new Map(); - const taskRefs = new Map(); - const containerRefs = new Map(); + const totalRows = createMemo(() => + groupedRows().reduce((acc, group) => acc + group.rows.length, 0), + ); - const assignHostRef = (hostId: string, el: HTMLElement | null | undefined) => { - if (el) { - hostRefs.set(hostId, el); - } else { - hostRefs.delete(hostId); - } - }; + const totalContainers = createMemo(() => + (props.hosts || []).reduce((acc, host) => acc + (host.containers?.length ?? 0), 0), + ); + const totalServices = createMemo(() => + (props.hosts || []).reduce((acc, host) => acc + (host.services?.length ?? 0), 0), + ); - const assignServiceRef = (serviceKey: string, el: HTMLTableRowElement | null | undefined) => { - if (el) { - serviceRefs.set(serviceKey, el); - } else { - serviceRefs.delete(serviceKey); - } - }; - - const registerTaskRef = (nodeId: string, row: HTMLTableRowElement) => { - taskRefs.set(nodeId, row); - }; - - const unregisterTaskRef = (nodeId: string) => { - taskRefs.delete(nodeId); - }; - - const assignContainerRef = (nodeId: string, el: HTMLTableRowElement | null | undefined) => { - if (el) { - containerRefs.set(nodeId, el); - } else { - containerRefs.delete(nodeId); - } - }; - - const scrollToSelection = (selection: DockerTreeSelection, smooth = true) => { - if (typeof window === 'undefined') return false; - - const verticalScroll = ( - element?: Element | null, - block: ScrollLogicalPosition = 'nearest', - ) => { - if (!element) return false; - const rect = element.getBoundingClientRect(); - const topAllowance = 96; - const bottomAllowance = window.innerHeight - 32; - - if (rect.top >= topAllowance && rect.bottom <= bottomAllowance) { - return true; - } - - element.scrollIntoView({ - behavior: smooth ? 'smooth' : 'auto', - block, - inline: 'nearest', - }); - return true; - }; - - if (selection.type === 'host') { - return verticalScroll(hostRefs.get(selection.hostId), 'start'); - } - - if (selection.type === 'service') { - return verticalScroll(serviceRefs.get(selection.id), 'nearest'); - } - - if (selection.type === 'task') { - return verticalScroll(taskRefs.get(selection.id), 'center'); - } - - if (selection.type === 'container') { - return verticalScroll(containerRefs.get(selection.id), 'nearest'); - } - - return false; - }; - - const handleTreeSelect = (selection: DockerTreeSelection) => { - batch(() => { - setSelectedNode(selection); - - const hostState = getHostExpandState(selection.hostId); - hostState.setExpanded(true); - - if (selection.type === 'task') { - const serviceState = getServiceExpandState(selection.serviceKey); - serviceState.setExpanded(true); - } - }); - - setIsMobileTreeOpen(false); - }; - - const buildSelectionFingerprint = (selection: DockerTreeSelection) => { - switch (selection.type) { - case 'host': - return `host:${selection.hostId}`; - case 'service': - return `service:${selection.hostId}:${selection.id}`; - case 'task': - return `task:${selection.hostId}:${selection.serviceKey}:${selection.id}`; - case 'container': - return `container:${selection.hostId}:${selection.id}`; - default: - return ''; - } - }; - - let lastScrollFingerprint = ''; - - const attemptScrollToSelection = ( - selection: DockerTreeSelection, - fingerprint: string, - remainingAttempts = 8, - ) => { - if (remainingAttempts <= 0) return; - const didScroll = scrollToSelection(selection, remainingAttempts === 8); - if (didScroll) { - lastScrollFingerprint = fingerprint; - } else if (typeof window !== 'undefined') { - requestAnimationFrame(() => - attemptScrollToSelection(selection, fingerprint, remainingAttempts - 1), + const runningContainers = createMemo(() => + groupedRows().reduce((acc, group) => { + return ( + acc + + group.rows + .filter((row): row is Extract => row.kind === 'container') + .filter((row) => toLower(row.container.state) === 'running').length ); - } - }; + }, 0), + ); - createEffect(() => { - const selection = selectedNode(); - if (!selection) return; - const hosts = visibleHosts(); - const hostEntry = hosts.find((entry) => entry.hostId === selection.hostId); - if (!hostEntry) { - setSelectedNode(null); - return; - } - - if (selection.type === 'service') { - const exists = hostEntry.services.some((service) => service.key === selection.id); - if (!exists) { - setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); - return; - } - } else if (selection.type === 'task') { - const serviceEntry = hostEntry.services.find((service) => service.key === selection.serviceKey); - if (!serviceEntry) { - setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); - return; - } - const taskExists = serviceEntry.tasks.some((task) => task.nodeId === selection.id); - if (!taskExists) { - setSelectedNode({ type: 'service', hostId: hostEntry.hostId, id: serviceEntry.key }); - return; - } - } else if (selection.type === 'container') { - const exists = hostEntry.standaloneContainers.some((container) => container.nodeId === selection.id); - if (!exists) { - setSelectedNode({ type: 'host', hostId: hostEntry.hostId, id: hostEntry.hostId }); - return; - } - } - - const fingerprint = buildSelectionFingerprint(selection); - if (!fingerprint || fingerprint === lastScrollFingerprint) return; - - attemptScrollToSelection(selection, fingerprint); - }); - - const hasVisibleHosts = createMemo(() => visibleHosts().length > 0); + const stoppedContainers = createMemo(() => + groupedRows().reduce((acc, group) => { + return ( + acc + + group.rows + .filter((row): row is Extract => row.kind === 'container') + .filter((row) => STOPPED_CONTAINER_STATES.has(toLower(row.container.state))).length + ); + }, 0), + ); return ( -
- -
- +
+ 0} + fallback={ + + + + } + > + + +
TaskNodeStateCPUMemoryUpdated
- {taskLabel()} +
+
+ + {taskLabel()} + + + Task + +
+ {task.nodeName || task.nodeId || '—'} - - {task.currentState || 'Unknown'} + + + {task.currentState || task.desiredState || 'Unknown'} - —}> - + + 0} fallback={}> + - —}> - + + 0} fallback={}> + - {(() => { - const timestamp = task.startedAt || task.createdAt; - if (!timestamp) return '—'; - // Handle both Unix timestamps (number) and ISO strings (from backend time.Time) - const date = typeof timestamp === 'number' - ? new Date(timestamp * 1000) - : new Date(timestamp); - return date.toLocaleString(); - })()} + + + {(timestamp) => ( + + {formatRelativeTime(timestamp())} + + )} +
- {props.container.name || props.container.id?.slice(0, 12)} - - - {props.container.image || 'Image not specified'} - - {stateBadge()} - —}> - - - - —}> - - - - {formatPorts()} -
+ + + + + + + + + + + + + + + {(group) => ( + <> + + + {(row) => + row.kind === 'container' ? ( + + ) : ( + + ) + } + + + )} + + +
+ Resource + + Type + + Image / Stack + + Status + + CPU + + Memory + + Tasks / Restarts + + Updated / Uptime +
+ + + +
+ + + | + +
- - - - - - -
-
setIsMobileTreeOpen(false)} - /> - -
- - -
- - {(entry) => { - const hostState = getHostExpandState(entry.hostId); - const currentSelection = () => selectedNode(); - const isHostActive = () => currentSelection()?.hostId === entry.hostId; - - const displayedServices = createMemo(() => { - const selection = currentSelection(); - if (!selection || selection.type === 'host') return entry.services; - if (selection.type === 'service') { - return entry.services.filter((service) => service.key === selection.id); - } - if (selection.type === 'task') { - return entry.services.filter((service) => service.key === selection.serviceKey); - } - return []; - }); - - const displayedStandaloneContainers = createMemo(() => { - const selection = currentSelection(); - if (!selection || selection.type === 'host') return entry.standaloneContainers; - if (selection.type === 'container') { - return entry.standaloneContainers.filter((container) => container.nodeId === selection.id); - } - return []; - }); - - const shouldShowServices = createMemo(() => displayedServices().length > 0); - const shouldShowContainers = createMemo(() => displayedStandaloneContainers().length > 0); - - return ( -
assignHostRef(entry.hostId, el)} - class="space-y-4 scroll-mt-28 min-w-0" - > - - - - - -
-
- - -
- - - - - - - - - - - - - - {(serviceEntry) => { - const serviceState = getServiceExpandState(serviceEntry.key); - const serviceSelected = () => { - const selection = selectedNode(); - if (!selection) return false; - if (selection.type === 'service') { - return selection.id === serviceEntry.key; - } - if (selection.type === 'task') { - return selection.serviceKey === serviceEntry.key; - } - return false; - }; - const selectedTaskId = () => { - const selection = selectedNode(); - if (selection?.type === 'task' && selection.serviceKey === serviceEntry.key) { - return selection.id; - } - return null; - }; - - onCleanup(() => { - serviceRefs.delete(serviceEntry.key); - }); - - return ( - assignServiceRef(serviceEntry.key, row)} - selectedTaskId={selectedTaskId()} - onTaskMount={registerTaskRef} - onTaskUnmount={unregisterTaskRef} - /> - ); - }} - - -
- Service - - Image - - Status -
-
-
-
- - - - - - - - - - - - - - - - - - {(containerEntry) => { - const isContainerSelected = () => - selectedNode()?.type === 'container' && - selectedNode()?.id === containerEntry.nodeId; - - return ( - assignContainerRef(containerEntry.nodeId, row)} - /> - ); - }} - - -
- Container - - Image - - Status - - CPU - - Memory - - Ports -
-
-
-
-
-
-
- ); - }} -
-
); }; + +export { DockerUnifiedTable }; diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 1c37f23..08e206e 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -1,6 +1,6 @@ import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api'; -import { formatBytes, formatUptime } from '@/utils/format'; +import { formatBytes, formatPercent, formatUptime } from '@/utils/format'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; @@ -563,7 +563,7 @@ export const NodeSummaryTable: Component = (props) => { > = (props) => { > = (props) => { > diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 26982e3..7519e4c 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -130,6 +130,11 @@ export interface DockerHost { cpus: number; totalMemoryBytes: number; uptimeSeconds: number; + cpuUsagePercent?: number; + loadAverage?: number[]; + memory?: Memory; + disks?: Disk[]; + networkInterfaces?: HostNetworkInterface[]; status: string; lastSeen: number; intervalSeconds: number; diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 9906674..08196c6 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -15,6 +15,23 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string { return `${formatBytes(bytesPerSecond, decimals)}/s`; } +export function formatPercent(value: number): string { + if (!Number.isFinite(value)) return '0%'; + + const abs = Math.abs(value); + + if (abs >= 10) { + return `${Math.round(value)}%`; + } + + if (abs >= 1) { + return `${value.toFixed(1)}%`; + } + + // Preserve tiny signals without overly long labels + return `${value.toFixed(2)}%`; +} + export function formatUptime(seconds: number): string { if (!seconds || seconds < 0) return '0s'; diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index a82bb7f..c681ca7 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -21,6 +21,7 @@ import ( containertypes "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" + "github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" "github.com/rs/zerolog" ) @@ -332,6 +333,13 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) { uptime := readSystemUptime() + metricsCtx, metricsCancel := context.WithTimeout(ctx, 10*time.Second) + snapshot, err := hostmetrics.Collect(metricsCtx) + metricsCancel() + if err != nil { + return agentsdocker.Report{}, fmt.Errorf("collect host metrics: %w", err) + } + collectContainers := a.cfg.IncludeContainers if !collectContainers && (a.cfg.IncludeServices || a.cfg.IncludeTasks) && !info.Swarm.ControlAvailable { collectContainers = true @@ -365,6 +373,11 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) { TotalCPU: info.NCPU, TotalMemoryBytes: info.MemTotal, UptimeSeconds: uptime, + CPUUsagePercent: safeFloat(snapshot.CPUUsagePercent), + LoadAverage: append([]float64(nil), snapshot.LoadAverage...), + Memory: snapshot.Memory, + Disks: append([]agentsdocker.Disk(nil), snapshot.Disks...), + Network: append([]agentsdocker.NetworkInterface(nil), snapshot.Network...), }, Timestamp: time.Now().UTC(), } diff --git a/internal/dockeragent/version.go b/internal/dockeragent/version.go index f05ff53..d740c36 100644 --- a/internal/dockeragent/version.go +++ b/internal/dockeragent/version.go @@ -4,4 +4,4 @@ package dockeragent // overridden at build time via -ldflags for release artifacts. When building // from source without ldflags, it defaults to this development value. // Set to match deployed agents to prevent update loops in development. -var Version = "v4.22.0-rc.6" +var Version = "v4.30.0" diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index fa1fde7..12d23af 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -9,18 +9,13 @@ import ( "fmt" "net/http" "runtime" - "sort" "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" "github.com/rs/zerolog" - gocpu "github.com/shirou/gopsutil/v4/cpu" - godisk "github.com/shirou/gopsutil/v4/disk" gohost "github.com/shirou/gopsutil/v4/host" - goload "github.com/shirou/gopsutil/v4/load" - gomem "github.com/shirou/gopsutil/v4/mem" - gonet "github.com/shirou/gopsutil/v4/net" ) // Config controls the behaviour of the host agent. @@ -220,29 +215,9 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) { defer cancel() uptime, _ := gohost.UptimeWithContext(collectCtx) - loadAvg, _ := goload.AvgWithContext(collectCtx) - cpuCount, _ := gocpu.CountsWithContext(collectCtx, true) - cpuUsage, err := a.calculateCPUUsage(collectCtx) + snapshot, err := hostmetrics.Collect(collectCtx) if err != nil { - a.logger.Debug().Err(err).Msg("failed to compute cpu usage") - } - - memStats, err := gomem.VirtualMemoryWithContext(collectCtx) - if err != nil { - return agentshost.Report{}, fmt.Errorf("memory stats: %w", err) - } - - disks := a.collectDisks(collectCtx) - network := a.collectNetwork(collectCtx) - - var loadValues []float64 - if loadAvg != nil { - loadValues = []float64{loadAvg.Load1, loadAvg.Load5, loadAvg.Load15} - } - - swapUsed := int64(0) - if memStats.SwapTotal > memStats.SwapFree { - swapUsed = int64(memStats.SwapTotal - memStats.SwapFree) + return agentshost.Report{}, fmt.Errorf("collect metrics: %w", err) } report := agentshost.Report{ @@ -263,23 +238,16 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) { KernelVersion: a.kernelVersion, Architecture: a.architecture, CPUModel: "", - CPUCount: cpuCount, + CPUCount: snapshot.CPUCount, UptimeSeconds: int64(uptime), - LoadAverage: loadValues, + LoadAverage: append([]float64(nil), snapshot.LoadAverage...), }, Metrics: agentshost.Metrics{ - CPUUsagePercent: cpuUsage, - Memory: agentshost.MemoryMetric{ - TotalBytes: int64(memStats.Total), - UsedBytes: int64(memStats.Used), - FreeBytes: int64(memStats.Free), - Usage: memStats.UsedPercent, - SwapTotal: int64(memStats.SwapTotal), - SwapUsed: swapUsed, - }, + CPUUsagePercent: snapshot.CPUUsagePercent, + Memory: snapshot.Memory, }, - Disks: disks, - Network: network, + Disks: append([]agentshost.Disk(nil), snapshot.Disks...), + Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...), Sensors: agentshost.Sensors{}, Tags: append([]string(nil), a.cfg.Tags...), Timestamp: time.Now().UTC(), @@ -288,123 +256,6 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) { return report, nil } -func (a *Agent) calculateCPUUsage(ctx context.Context) (float64, error) { - // Use Percent() with a 1 second measurement interval for cross-platform compatibility - // This works reliably on macOS ARM64 where Times() is not implemented - percentages, err := gocpu.PercentWithContext(ctx, time.Second, false) - if err != nil { - return 0, err - } - if len(percentages) == 0 { - return 0, nil - } - - usage := percentages[0] - if usage < 0 { - usage = 0 - } - if usage > 100 { - usage = 100 - } - - return usage, nil -} - -func (a *Agent) collectDisks(ctx context.Context) []agentshost.Disk { - partitions, err := godisk.PartitionsWithContext(ctx, true) - if err != nil { - a.logger.Debug().Err(err).Msg("failed to fetch disk partitions") - return nil - } - - disks := make([]agentshost.Disk, 0, len(partitions)) - seen := make(map[string]struct{}, len(partitions)) - - for _, part := range partitions { - if part.Mountpoint == "" { - continue - } - if _, ok := seen[part.Mountpoint]; ok { - continue - } - seen[part.Mountpoint] = struct{}{} - - usage, err := godisk.UsageWithContext(ctx, part.Mountpoint) - if err != nil { - continue - } - if usage.Total == 0 { - continue - } - - disks = append(disks, agentshost.Disk{ - Device: part.Device, - Mountpoint: part.Mountpoint, - Filesystem: part.Fstype, - Type: part.Fstype, - TotalBytes: int64(usage.Total), - UsedBytes: int64(usage.Used), - FreeBytes: int64(usage.Free), - Usage: usage.UsedPercent, - }) - } - - sort.Slice(disks, func(i, j int) bool { return disks[i].Mountpoint < disks[j].Mountpoint }) - return disks -} - -func (a *Agent) collectNetwork(ctx context.Context) []agentshost.NetworkInterface { - ifaces, err := gonet.InterfacesWithContext(ctx) - if err != nil { - a.logger.Debug().Err(err).Msg("failed to fetch network interfaces") - return nil - } - - ioCounters, err := gonet.IOCountersWithContext(ctx, true) - if err != nil { - a.logger.Debug().Err(err).Msg("failed to fetch network counters") - } - ioMap := make(map[string]gonet.IOCountersStat, len(ioCounters)) - for _, stat := range ioCounters { - ioMap[stat.Name] = stat - } - - interfaces := make([]agentshost.NetworkInterface, 0, len(ifaces)) - - for _, iface := range ifaces { - if len(iface.Addrs) == 0 { - continue - } - if isLoopback(iface.Flags) { - continue - } - - addresses := make([]string, 0, len(iface.Addrs)) - for _, addr := range iface.Addrs { - if addr.Addr != "" { - addresses = append(addresses, addr.Addr) - } - } - if len(addresses) == 0 { - continue - } - - counter := ioMap[iface.Name] - ifaceEntry := agentshost.NetworkInterface{ - Name: iface.Name, - MAC: iface.HardwareAddr, - Addresses: addresses, - RXBytes: counter.BytesRecv, - TXBytes: counter.BytesSent, - } - - interfaces = append(interfaces, ifaceEntry) - } - - sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name }) - return interfaces -} - func (a *Agent) sendReport(ctx context.Context, report agentshost.Report) error { payload, err := json.Marshal(report) if err != nil { diff --git a/internal/hostmetrics/collector.go b/internal/hostmetrics/collector.go new file mode 100644 index 0000000..f5d31d3 --- /dev/null +++ b/internal/hostmetrics/collector.go @@ -0,0 +1,191 @@ +package hostmetrics + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" + gocpu "github.com/shirou/gopsutil/v4/cpu" + godisk "github.com/shirou/gopsutil/v4/disk" + goload "github.com/shirou/gopsutil/v4/load" + gomem "github.com/shirou/gopsutil/v4/mem" + gonet "github.com/shirou/gopsutil/v4/net" +) + +// Snapshot represents a host resource utilisation sample. +type Snapshot struct { + CPUUsagePercent float64 + CPUCount int + LoadAverage []float64 + Memory agentshost.MemoryMetric + Disks []agentshost.Disk + Network []agentshost.NetworkInterface +} + +// Collect gathers a point-in-time snapshot of host resource utilisation. +func Collect(ctx context.Context) (Snapshot, error) { + collectCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + var snapshot Snapshot + + if cpuCount, err := gocpu.CountsWithContext(collectCtx, true); err == nil { + snapshot.CPUCount = cpuCount + } + + if cpuUsage, err := collectCPUUsage(collectCtx); err == nil { + snapshot.CPUUsagePercent = cpuUsage + } + + if loadAvg, err := goload.AvgWithContext(collectCtx); err == nil && loadAvg != nil { + snapshot.LoadAverage = []float64{loadAvg.Load1, loadAvg.Load5, loadAvg.Load15} + } + + memStats, err := gomem.VirtualMemoryWithContext(collectCtx) + if err != nil { + return Snapshot{}, fmt.Errorf("memory stats: %w", err) + } + + swapUsed := int64(0) + if memStats.SwapTotal > memStats.SwapFree { + swapUsed = int64(memStats.SwapTotal - memStats.SwapFree) + } + + snapshot.Memory = agentshost.MemoryMetric{ + TotalBytes: int64(memStats.Total), + UsedBytes: int64(memStats.Used), + FreeBytes: int64(memStats.Free), + Usage: memStats.UsedPercent, + SwapTotal: int64(memStats.SwapTotal), + SwapUsed: swapUsed, + } + + snapshot.Disks = collectDisks(collectCtx) + snapshot.Network = collectNetwork(collectCtx) + + return snapshot, nil +} + +func collectCPUUsage(ctx context.Context) (float64, error) { + percentages, err := gocpu.PercentWithContext(ctx, time.Second, false) + if err != nil { + return 0, err + } + if len(percentages) == 0 { + return 0, nil + } + + usage := percentages[0] + if usage < 0 { + usage = 0 + } + if usage > 100 { + usage = 100 + } + return usage, nil +} + +func collectDisks(ctx context.Context) []agentshost.Disk { + partitions, err := godisk.PartitionsWithContext(ctx, true) + if err != nil { + return nil + } + + disks := make([]agentshost.Disk, 0, len(partitions)) + seen := make(map[string]struct{}, len(partitions)) + + for _, part := range partitions { + if part.Mountpoint == "" { + continue + } + if _, ok := seen[part.Mountpoint]; ok { + continue + } + seen[part.Mountpoint] = struct{}{} + + usage, err := godisk.UsageWithContext(ctx, part.Mountpoint) + if err != nil { + continue + } + if usage.Total == 0 { + continue + } + + disks = append(disks, agentshost.Disk{ + Device: part.Device, + Mountpoint: part.Mountpoint, + Filesystem: part.Fstype, + Type: part.Fstype, + TotalBytes: int64(usage.Total), + UsedBytes: int64(usage.Used), + FreeBytes: int64(usage.Free), + Usage: usage.UsedPercent, + }) + } + + sort.Slice(disks, func(i, j int) bool { return disks[i].Mountpoint < disks[j].Mountpoint }) + return disks +} + +func collectNetwork(ctx context.Context) []agentshost.NetworkInterface { + ifaces, err := gonet.InterfacesWithContext(ctx) + if err != nil { + return nil + } + + ioCounters, err := gonet.IOCountersWithContext(ctx, true) + if err != nil { + ioCounters = nil + } + ioMap := make(map[string]gonet.IOCountersStat, len(ioCounters)) + for _, stat := range ioCounters { + ioMap[stat.Name] = stat + } + + interfaces := make([]agentshost.NetworkInterface, 0, len(ifaces)) + + for _, iface := range ifaces { + if len(iface.Addrs) == 0 { + continue + } + if isLoopback(iface.Flags) { + continue + } + + addresses := make([]string, 0, len(iface.Addrs)) + for _, addr := range iface.Addrs { + if addr.Addr != "" { + addresses = append(addresses, addr.Addr) + } + } + if len(addresses) == 0 { + continue + } + + counter := ioMap[iface.Name] + ifaceEntry := agentshost.NetworkInterface{ + Name: iface.Name, + MAC: iface.HardwareAddr, + Addresses: addresses, + RXBytes: counter.BytesRecv, + TXBytes: counter.BytesSent, + } + + interfaces = append(interfaces, ifaceEntry) + } + + sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name }) + return interfaces +} + +func isLoopback(flags []string) bool { + for _, flag := range flags { + if strings.EqualFold(flag, "loopback") { + return true + } + } + return false +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 2d27a42..9ac26f1 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -1115,27 +1115,94 @@ func generateDockerHosts(config MockConfig) []models.DockerHost { services, tasks = generateDockerServicesAndTasks(hostname, containers, now) } + cpuUsage := clampFloat(10+rand.Float64()*70, 4, 98) + loadAverage := []float64{ + clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1), + clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1), + clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1), + } + + memUsageRatio := clampFloat(0.3+rand.Float64()*0.55, 0.05, 0.98) + usedMemoryBytes := int64(float64(totalMemoryBytes) * memUsageRatio) + if usedMemoryBytes > totalMemoryBytes { + usedMemoryBytes = totalMemoryBytes + } + freeMemoryBytes := totalMemoryBytes - usedMemoryBytes + memory := models.Memory{ + Total: totalMemoryBytes, + Used: usedMemoryBytes, + Free: freeMemoryBytes, + } + if totalMemoryBytes > 0 { + memory.Usage = clampFloat((float64(usedMemoryBytes)/float64(totalMemoryBytes))*100, 0, 100) + } + + diskTotal := int64((250 + rand.Intn(750)) * 1024 * 1024 * 1024) // 250-999 GB + diskUsed := int64(float64(diskTotal) * clampFloat(0.35+rand.Float64()*0.5, 0.1, 0.97)) + if diskUsed > diskTotal { + diskUsed = diskTotal + } + diskFree := diskTotal - diskUsed + diskUsage := 0.0 + if diskTotal > 0 { + diskUsage = clampFloat((float64(diskUsed)/float64(diskTotal))*100, 0, 100) + } + + disks := []models.Disk{ + { + Total: diskTotal, + Used: diskUsed, + Free: diskFree, + Usage: diskUsage, + Mountpoint: "/", + Type: "ext4", + Device: "/dev/sda1", + }, + } + + networkInterfaces := []models.HostNetworkInterface{ + { + Name: "eth0", + Addresses: []string{fmt.Sprintf("10.10.%d.%d/24", i%20, rand.Intn(200)+10)}, + RXBytes: uint64(rand.Int63n(5_000_000_000) + 500_000_000), + TXBytes: uint64(rand.Int63n(4_000_000_000) + 400_000_000), + }, + } + if rand.Intn(4) == 0 { + networkInterfaces = append(networkInterfaces, models.HostNetworkInterface{ + Name: "eth1", + Addresses: []string{fmt.Sprintf("172.16.%d.%d/24", i%16, rand.Intn(200)+20)}, + RXBytes: uint64(rand.Int63n(2_000_000_000) + 200_000_000), + TXBytes: uint64(rand.Int63n(1_500_000_000) + 150_000_000), + }) + } + host := models.DockerHost{ - ID: hostID, - AgentID: fmt.Sprintf("agent-%s", randomHexString(6)), - Hostname: hostname, - DisplayName: humanizeHostDisplayName(hostname), - MachineID: randomHexString(32), - OS: dockerOperatingSystems[rand.Intn(len(dockerOperatingSystems))], - KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))], - Architecture: dockerArchitectures[rand.Intn(len(dockerArchitectures))], - DockerVersion: dockerVersions[rand.Intn(len(dockerVersions))], - CPUs: cpus, - TotalMemoryBytes: totalMemoryBytes, - UptimeSeconds: uptime, - Status: status, - LastSeen: lastSeen, - IntervalSeconds: interval, - AgentVersion: agentVersion, - Containers: containers, - Services: services, - Tasks: tasks, - Swarm: swarmInfo, + ID: hostID, + AgentID: fmt.Sprintf("agent-%s", randomHexString(6)), + Hostname: hostname, + DisplayName: humanizeHostDisplayName(hostname), + MachineID: randomHexString(32), + OS: dockerOperatingSystems[rand.Intn(len(dockerOperatingSystems))], + KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))], + Architecture: dockerArchitectures[rand.Intn(len(dockerArchitectures))], + DockerVersion: dockerVersions[rand.Intn(len(dockerVersions))], + CPUs: cpus, + TotalMemoryBytes: totalMemoryBytes, + UptimeSeconds: uptime, + CPUUsage: cpuUsage, + LoadAverage: loadAverage, + Memory: memory, + Disks: disks, + NetworkInterfaces: networkInterfaces, + Status: status, + LastSeen: lastSeen, + IntervalSeconds: interval, + AgentVersion: agentVersion, + Containers: containers, + Services: services, + Tasks: tasks, + Swarm: swarmInfo, } hosts = append(hosts, host) diff --git a/internal/models/converters.go b/internal/models/converters.go index 0358e23..26289ed 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -208,6 +208,7 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { CPUs: d.CPUs, TotalMemoryBytes: d.TotalMemoryBytes, UptimeSeconds: d.UptimeSeconds, + CPUUsagePercent: d.CPUUsage, Status: d.Status, LastSeen: d.LastSeen.Unix() * 1000, IntervalSeconds: d.IntervalSeconds, @@ -254,6 +255,24 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { h.Swarm = &sw } + if len(d.LoadAverage) > 0 { + h.LoadAverage = append([]float64(nil), d.LoadAverage...) + } + + if (d.Memory != Memory{}) { + mem := d.Memory + h.Memory = &mem + } + + if len(d.Disks) > 0 { + h.Disks = append([]Disk(nil), d.Disks...) + } + + if len(d.NetworkInterfaces) > 0 { + h.NetworkInterfaces = make([]HostNetworkInterface, len(d.NetworkInterfaces)) + copy(h.NetworkInterfaces, d.NetworkInterfaces) + } + if d.Command != nil { h.Command = toDockerHostCommandFrontend(*d.Command) } diff --git a/internal/models/models.go b/internal/models/models.go index 96a8556..e3668b0 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -190,33 +190,38 @@ type HostSensorSummary struct { // DockerHost represents a Docker host reporting metrics via the external agent. type DockerHost struct { - ID string `json:"id"` - AgentID string `json:"agentId"` - Hostname string `json:"hostname"` - DisplayName string `json:"displayName"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - CPUs int `json:"cpus"` - TotalMemoryBytes int64 `json:"totalMemoryBytes"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Status string `json:"status"` - LastSeen time.Time `json:"lastSeen"` - IntervalSeconds int `json:"intervalSeconds"` - AgentVersion string `json:"agentVersion,omitempty"` - Containers []DockerContainer `json:"containers"` - Services []DockerService `json:"services,omitempty"` - Tasks []DockerTask `json:"tasks,omitempty"` - Swarm *DockerSwarmInfo `json:"swarm,omitempty"` - TokenID string `json:"tokenId,omitempty"` - TokenName string `json:"tokenName,omitempty"` - TokenHint string `json:"tokenHint,omitempty"` - TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` - Hidden bool `json:"hidden"` - PendingUninstall bool `json:"pendingUninstall"` - Command *DockerHostCommandStatus `json:"command,omitempty"` + ID string `json:"id"` + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + CPUs int `json:"cpus"` + TotalMemoryBytes int64 `json:"totalMemoryBytes"` + UptimeSeconds int64 `json:"uptimeSeconds"` + CPUUsage float64 `json:"cpuUsagePercent"` + LoadAverage []float64 `json:"loadAverage,omitempty"` + Memory Memory `json:"memory"` + Disks []Disk `json:"disks,omitempty"` + NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` + Status string `json:"status"` + LastSeen time.Time `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + Containers []DockerContainer `json:"containers"` + Services []DockerService `json:"services,omitempty"` + Tasks []DockerTask `json:"tasks,omitempty"` + Swarm *DockerSwarmInfo `json:"swarm,omitempty"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` + Hidden bool `json:"hidden"` + PendingUninstall bool `json:"pendingUninstall"` + Command *DockerHostCommandStatus `json:"command,omitempty"` } // DockerContainer represents the state of a Docker container on a monitored host. diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index cf3cb09..7049796 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -97,32 +97,37 @@ type ContainerFrontend struct { // DockerHostFrontend represents a Docker host with frontend-friendly fields type DockerHostFrontend struct { - ID string `json:"id"` - AgentID string `json:"agentId"` - Hostname string `json:"hostname"` - DisplayName string `json:"displayName"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - CPUs int `json:"cpus"` - TotalMemoryBytes int64 `json:"totalMemoryBytes"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Status string `json:"status"` - LastSeen int64 `json:"lastSeen"` - IntervalSeconds int `json:"intervalSeconds"` - AgentVersion string `json:"agentVersion,omitempty"` - Containers []DockerContainerFrontend `json:"containers"` - Services []DockerServiceFrontend `json:"services,omitempty"` - Tasks []DockerTaskFrontend `json:"tasks,omitempty"` - Swarm *DockerSwarmFrontend `json:"swarm,omitempty"` - TokenID string `json:"tokenId,omitempty"` - TokenName string `json:"tokenName,omitempty"` - TokenHint string `json:"tokenHint,omitempty"` - TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` - PendingUninstall bool `json:"pendingUninstall"` - Command *DockerHostCommandFrontend `json:"command,omitempty"` + ID string `json:"id"` + AgentID string `json:"agentId"` + Hostname string `json:"hostname"` + DisplayName string `json:"displayName"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + CPUs int `json:"cpus"` + TotalMemoryBytes int64 `json:"totalMemoryBytes"` + UptimeSeconds int64 `json:"uptimeSeconds"` + CPUUsagePercent float64 `json:"cpuUsagePercent"` + LoadAverage []float64 `json:"loadAverage,omitempty"` + Memory *Memory `json:"memory,omitempty"` + Disks []Disk `json:"disks,omitempty"` + NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` + Status string `json:"status"` + LastSeen int64 `json:"lastSeen"` + IntervalSeconds int `json:"intervalSeconds"` + AgentVersion string `json:"agentVersion,omitempty"` + Containers []DockerContainerFrontend `json:"containers"` + Services []DockerServiceFrontend `json:"services,omitempty"` + Tasks []DockerTaskFrontend `json:"tasks,omitempty"` + Swarm *DockerSwarmFrontend `json:"swarm,omitempty"` + TokenID string `json:"tokenId,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"` + PendingUninstall bool `json:"pendingUninstall"` + Command *DockerHostCommandFrontend `json:"command,omitempty"` } // DockerContainerFrontend represents a Docker container for the frontend diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index a041e21..034e4b3 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -646,6 +646,18 @@ func convertDockerTasks(tasks []agentsdocker.Task) []models.DockerTask { return result } +func normalizeAgentVersion(version string) string { + version = strings.TrimSpace(version) + if version == "" { + return "" + } + version = strings.TrimLeft(version, "vV") + if version == "" { + return "" + } + return "v" + version +} + func convertDockerSwarmInfo(info *agentsdocker.SwarmInfo) *models.DockerSwarmInfo { if info == nil { return nil @@ -1390,27 +1402,80 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con tasks := convertDockerTasks(report.Tasks) swarmInfo := convertDockerSwarmInfo(report.Host.Swarm) + loadAverage := make([]float64, 0, len(report.Host.LoadAverage)) + if len(report.Host.LoadAverage) > 0 { + loadAverage = append(loadAverage, report.Host.LoadAverage...) + } + + var memory models.Memory + if report.Host.Memory.TotalBytes > 0 || report.Host.Memory.UsedBytes > 0 { + memory = models.Memory{ + Total: report.Host.Memory.TotalBytes, + Used: report.Host.Memory.UsedBytes, + Free: report.Host.Memory.FreeBytes, + Usage: safeFloat(report.Host.Memory.Usage), + SwapTotal: report.Host.Memory.SwapTotal, + SwapUsed: report.Host.Memory.SwapUsed, + } + } + + disks := make([]models.Disk, 0, len(report.Host.Disks)) + for _, disk := range report.Host.Disks { + disks = append(disks, models.Disk{ + Total: disk.TotalBytes, + Used: disk.UsedBytes, + Free: disk.FreeBytes, + Usage: safeFloat(disk.Usage), + Mountpoint: disk.Mountpoint, + Type: disk.Type, + Device: disk.Device, + }) + } + + networkIfaces := make([]models.HostNetworkInterface, 0, len(report.Host.Network)) + for _, iface := range report.Host.Network { + addresses := append([]string(nil), iface.Addresses...) + networkIfaces = append(networkIfaces, models.HostNetworkInterface{ + Name: iface.Name, + MAC: iface.MAC, + Addresses: addresses, + RXBytes: iface.RXBytes, + TXBytes: iface.TXBytes, + SpeedMbps: iface.SpeedMbps, + }) + } + + agentVersion := normalizeAgentVersion(report.Agent.Version) + if agentVersion == "" && hasPrevious { + agentVersion = normalizeAgentVersion(previous.AgentVersion) + } + host := models.DockerHost{ - ID: identifier, - AgentID: agentID, - Hostname: hostname, - DisplayName: displayName, - MachineID: strings.TrimSpace(report.Host.MachineID), - OS: report.Host.OS, - KernelVersion: report.Host.KernelVersion, - Architecture: report.Host.Architecture, - DockerVersion: report.Host.DockerVersion, - CPUs: report.Host.TotalCPU, - TotalMemoryBytes: report.Host.TotalMemoryBytes, - UptimeSeconds: report.Host.UptimeSeconds, - Status: "online", - LastSeen: timestamp, - IntervalSeconds: report.Agent.IntervalSeconds, - AgentVersion: report.Agent.Version, - Containers: containers, - Services: services, - Tasks: tasks, - Swarm: swarmInfo, + ID: identifier, + AgentID: agentID, + Hostname: hostname, + DisplayName: displayName, + MachineID: strings.TrimSpace(report.Host.MachineID), + OS: report.Host.OS, + KernelVersion: report.Host.KernelVersion, + Architecture: report.Host.Architecture, + DockerVersion: report.Host.DockerVersion, + CPUs: report.Host.TotalCPU, + TotalMemoryBytes: report.Host.TotalMemoryBytes, + UptimeSeconds: report.Host.UptimeSeconds, + CPUUsage: safeFloat(report.Host.CPUUsagePercent), + LoadAverage: loadAverage, + Memory: memory, + Disks: disks, + NetworkInterfaces: networkIfaces, + Status: "online", + LastSeen: timestamp, + IntervalSeconds: report.Agent.IntervalSeconds, + AgentVersion: agentVersion, + Containers: containers, + Services: services, + Tasks: tasks, + Swarm: swarmInfo, } if tokenRecord != nil { diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index 4027a3d..bd38c25 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -1,6 +1,14 @@ package dockeragent -import "time" +import ( + "time" + + hostagent "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" +) + +type MemoryMetric = hostagent.MemoryMetric +type Disk = hostagent.Disk +type NetworkInterface = hostagent.NetworkInterface // Report represents a single heartbeat from the Docker agent to Pulse. type Report struct { @@ -21,17 +29,22 @@ type AgentInfo struct { // HostInfo contains metadata about the Docker host where the agent runs. type HostInfo struct { - Hostname string `json:"hostname"` - Name string `json:"name,omitempty"` - MachineID string `json:"machineId,omitempty"` - OS string `json:"os,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - DockerVersion string `json:"dockerVersion,omitempty"` - TotalCPU int `json:"totalCpu,omitempty"` - TotalMemoryBytes int64 `json:"totalMemoryBytes,omitempty"` - UptimeSeconds int64 `json:"uptimeSeconds,omitempty"` - Swarm *SwarmInfo `json:"swarm,omitempty"` + Hostname string `json:"hostname"` + Name string `json:"name,omitempty"` + MachineID string `json:"machineId,omitempty"` + OS string `json:"os,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + DockerVersion string `json:"dockerVersion,omitempty"` + TotalCPU int `json:"totalCpu,omitempty"` + TotalMemoryBytes int64 `json:"totalMemoryBytes,omitempty"` + UptimeSeconds int64 `json:"uptimeSeconds,omitempty"` + Swarm *SwarmInfo `json:"swarm,omitempty"` + CPUUsagePercent float64 `json:"cpuUsagePercent,omitempty"` + LoadAverage []float64 `json:"loadAverage,omitempty"` + Memory MemoryMetric `json:"memory,omitempty"` + Disks []Disk `json:"disks,omitempty"` + Network []NetworkInterface `json:"network,omitempty"` } // Container captures the runtime state for a Docker container at report time. diff --git a/pulse-docker-agent b/pulse-docker-agent index a25e86f..e5e61d9 100755 Binary files a/pulse-docker-agent and b/pulse-docker-agent differ diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 2b6858d..684c3d5 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -175,30 +175,41 @@ FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} export FRONTEND_PORT PULSE_DEV_API_PORT PORT -# Set data directory based on mock mode to keep data isolated +# Set data directory strategy for the backend if [[ ${PULSE_MOCK_MODE:-false} == "true" ]]; then export PULSE_DATA_DIR=/opt/pulse/tmp/mock-data - # Ensure mock data directory exists mkdir -p "$PULSE_DATA_DIR" echo "[hot-dev] Mock mode: Using isolated data directory: ${PULSE_DATA_DIR}" else - DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config" - mkdir -p "$DEV_CONFIG_DIR" - export PULSE_DATA_DIR="${DEV_CONFIG_DIR}" - - DEV_KEY_FILE="${DEV_CONFIG_DIR}/.encryption.key" - if [[ ! -f "${DEV_KEY_FILE}" ]]; then - openssl rand -hex 32 > "${DEV_KEY_FILE}" - chmod 600 "${DEV_KEY_FILE}" - echo "[hot-dev] Generated dev encryption key at ${DEV_KEY_FILE}" + if [[ -n ${PULSE_DATA_DIR:-} ]]; then + echo "[hot-dev] Using preconfigured data directory: ${PULSE_DATA_DIR}" + elif [[ ${HOT_DEV_USE_PROD_DATA:-false} == "true" ]]; then + export PULSE_DATA_DIR=/etc/pulse + echo "[hot-dev] HOT_DEV_USE_PROD_DATA=true – using production data directory: ${PULSE_DATA_DIR}" + else + DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config" + mkdir -p "$DEV_CONFIG_DIR" + export PULSE_DATA_DIR="${DEV_CONFIG_DIR}" + echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}" fi - # Only set the encryption key if the user hasn't provided one explicitly + # Attempt to load encryption key automatically when not explicitly provided if [[ -z ${PULSE_ENCRYPTION_KEY:-} ]]; then - export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")" + if [[ -f "${PULSE_DATA_DIR}/.encryption.key" ]]; then + export PULSE_ENCRYPTION_KEY="$(<"${PULSE_DATA_DIR}/.encryption.key")" + echo "[hot-dev] Loaded encryption key from ${PULSE_DATA_DIR}/.encryption.key" + elif [[ ${PULSE_DATA_DIR} == "${ROOT_DIR}/tmp/dev-config" ]]; then + DEV_KEY_FILE="${PULSE_DATA_DIR}/.encryption.key" + if [[ ! -f "${DEV_KEY_FILE}" ]]; then + openssl rand -hex 32 > "${DEV_KEY_FILE}" + chmod 600 "${DEV_KEY_FILE}" + echo "[hot-dev] Generated dev encryption key at ${DEV_KEY_FILE}" + fi + export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")" + else + echo "[hot-dev] WARNING: No encryption key found for ${PULSE_DATA_DIR}. Encrypted config may fail to load." + fi fi - - echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}" fi ./pulse & diff --git a/scripts/install-docker-agent.sh b/scripts/install-docker-agent.sh index 50fcad8..a2b5767 100755 --- a/scripts/install-docker-agent.sh +++ b/scripts/install-docker-agent.sh @@ -877,6 +877,12 @@ if [[ "$UNINSTALL" != true ]]; then systemctl stop pulse-docker-agent fi fi + else + if pgrep -f pulse-docker-agent > /dev/null 2>&1; then + log_info "Stopping running agent process" + pkill -f pulse-docker-agent 2>/dev/null || true + sleep 1 + fi fi fi