- {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 (
- |
- |
);
};
-// 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.name || container.id}
+
+
+
+ ({container.id})
+
+
+
+ |
+
+
+ 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.name || service.id || 'Service'}
+
+
+ Service
+
+
+
+
+ Stack: {service.stack}
+
+
+
+ |
+
+
+ Service
+
+ |
+
+
+ {service.image || '—'}
+
+ |
+
+
+ {badge.label}
+
+ |
+ — |
+ — |
+
+
+ {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
+
+ tasks
+ |
+
+
+ {(timestamp) => (
+
+ {formatRelativeTime(timestamp())}
+
+ )}
+
+ |
+
+
+
-
-
-
- Task Containers ({props.tasks.length})
-
-
-
-
-
- | Task/Container |
- Node |
- State |
- CPU |
- Memory |
- Started |
-
-
-
-
- {(taskEntry) => {
- const task = taskEntry.task;
- const currentState = task.currentState?.toLowerCase() || 'unknown';
- const stateBadge = () => {
- if (currentState === 'running') {
+
+
+
+ Tasks
+
+ {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
+
+
+
+
+
+
+ | Task |
+ Node |
+ State |
+ CPU |
+ Memory |
+ Updated |
+
+
+
+
+ {(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' : ''
- }`}
- >
- |
- {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())}
+
+ )}
+
|
| |
);
@@ -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' : ''
- }`}
- >
- |
- {props.container.name || props.container.id?.slice(0, 12)}
- |
-
-
- {props.container.image || 'Image not specified'}
-
- |
- {stateBadge()} |
-
- —}>
-
-
- |
-
- —}>
-
-
- |
-
- {formatPorts()}
- |
-
- );
-};
-
-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={
+
+
+
+ }
+ >
+
+
+
+
+
+ |
+ Resource
+ |
+
+ Type
+ |
+
+ Image / Stack
+ |
+
+ Status
+ |
+
+ CPU
+ |
+
+ Memory
+ |
+
+ Tasks / Restarts
+ |
+
+ Updated / Uptime
+ |
+
+
+
+
+ {(group) => (
+ <>
+
+
+ {(row) =>
+ row.kind === 'container' ? (
+
+ ) : (
+
+ )
+ }
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ {runningContainers()} running
+
+ |
+
+
+ {stoppedContainers()} stopped
+
-
-
-
-
-
-
-
- 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"
- >
-
-
-
-
-
-
-
-
-
-
-
-
- |
- Service
- |
-
- Image
- |
-
- Status
- |
-
-
-
-
- {(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}
- />
- );
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
- Container
- |
-
- Image
- |
-
- Status
- |
-
- CPU
- |
-
- Memory
- |
-
- Ports
- |
-
-
-
-
- {(containerEntry) => {
- const isContainerSelected = () =>
- selectedNode()?.type === 'container' &&
- selectedNode()?.id === containerEntry.nodeId;
-
- return (
- assignContainerRef(containerEntry.nodeId, row)}
- />
- );
- }}
-
-
-
-
-
-
-
-
-
- );
- }}
-
-
);
};
+
+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
| | | |