Normalize docker agent version handling

This commit is contained in:
rcourtman 2025-10-28 08:42:58 +00:00
parent 7733d4b870
commit f2acdd59af
20 changed files with 1600 additions and 1492 deletions

View file

@ -45,12 +45,9 @@ export function MetricBar(props: MetricBarProps) {
});
return (
<div class="metric-text">
<div class="relative min-w-[96px] w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${progressColorClass()}`}
style={{ width: `${width()}%` }}
/>
<div class="metric-text w-full">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.label}</span>

View file

@ -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<DockerHostSummaryTableProps> = (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<DockerHostSummaryTableProps> = (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<DockerHostSummaryTableProps> = (p
>
Host {renderSortIndicator('name')}
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Status
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {renderSortIndicator('cpu')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {renderSortIndicator('memory')}
</th>
<th
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {renderSortIndicator('disk')}
</th>
<th
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('running')}
@ -151,19 +175,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
Containers {renderSortIndicator('running')}
</th>
<th
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('uptime')}
>
Uptime {renderSortIndicator('uptime')}
</th>
<th
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('lastSeen')}
>
Last Update {renderSortIndicator('lastSeen')}
</th>
<th
class="hidden px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 sm:table-cell"
onClick={() => handleSort('agent')}
>
Agent {renderSortIndicator('agent')}
@ -176,7 +200,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (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<string, string> = {};
@ -217,7 +241,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
style={rowStyle()}
onClick={() => props.onSelect(summary.host.id)}
>
<td class="pr-2 py-1 pl-3 align-top">
<td class="pr-2 py-1 pl-3 align-middle">
<div class="flex flex-wrap items-center gap-1">
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
{summary.host.displayName}
@ -274,80 +298,107 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</Show>
</div>
</td>
<td class="px-2 py-1 align-top">
{renderDockerStatusBadge(summary.host.status)}
</td>
<td class="px-2 py-1 align-top">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.cpuPercent}
label={`${summary.cpuPercent.toFixed(1)}%`}
type="cpu"
/>
</Show>
</td>
<td class="px-2 py-1 align-top">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.memoryPercent}
label={`${summary.memoryPercent.toFixed(1)}%`}
sublabel={summary.memoryLabel}
type="memory"
/>
</Show>
</td>
<td class="px-2 py-1 align-top">
<div class="flex justify-start sm:justify-center">
<MetricBar
value={summary.runningPercent}
label={`${summary.runningCount}/${summary.totalCount}`}
type="generic"
/>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
{renderDockerStatusBadge(summary.host.status)}
</div>
</td>
<td class="hidden px-2 py-1 align-top whitespace-nowrap sm:table-cell">
<span class="text-xs text-gray-600 dark:text-gray-400">
{uptimeLabel}
</span>
</td>
<td class="hidden px-2 py-1 align-top sm:table-cell">
<div class="text-sm text-gray-900 dark:text-gray-100">
{summary.lastSeenRelative}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar value={summary.cpuPercent} label={formatPercent(summary.cpuPercent)} type="cpu" />
</Show>
</div>
<Show when={summary.lastSeenAbsolute}>
<div class="text-xs text-gray-500 dark:text-gray-400">
{summary.lastSeenAbsolute}
</div>
</Show>
</td>
<td class="hidden px-2 py-1 align-top sm:table-cell">
<div class="flex flex-col gap-0.5">
<Show when={summary.host.agentVersion}>
<div class="flex flex-col gap-0.5">
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.memoryPercent}
label={formatPercent(summary.memoryPercent)}
sublabel={summary.memoryLabel}
type="memory"
/>
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full">
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.diskPercent}
label={formatPercent(summary.diskPercent)}
sublabel={summary.diskLabel}
type="disk"
/>
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full">
<Show
when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
<span
class={`text-xs font-medium whitespace-nowrap ${runningStatusClass(summary)}`}
title={`${formatPercent(summary.runningPercent)} running`}
>
{summary.runningCount}/{summary.totalCount}
</span>
</Show>
</div>
</td>
<td class="hidden px-2 py-1 align-middle whitespace-nowrap sm:table-cell">
<div class="flex justify-center items-center h-full">
<span class="text-xs text-gray-600 dark:text-gray-400">
{uptimeLabel}
</span>
</div>
</td>
<td class="hidden px-2 py-1 align-middle sm:table-cell">
<div class="flex justify-center items-center h-full">
<Show
when={summary.lastSeenRelative}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
{(relative) => (
<span
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
title={summary.lastSeenAbsolute || undefined}
>
{relative()}
</span>
)}
</Show>
</div>
</td>
<td class="hidden px-2 py-1 align-middle sm:table-cell">
<div class="flex items-center justify-center gap-2 whitespace-nowrap text-[10px] h-full">
<Show
when={summary.host.agentVersion}
fallback={<span class="text-gray-400 dark:text-gray-500 text-xs"></span>}
>
{(version) => (
<span
class={
agentOutdated
? 'text-[10px] px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium inline-block w-fit'
: 'text-[10px] px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium inline-block w-fit'
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
}
title={
title={`${
agentOutdated
? 'Outdated - Update recommended'
: 'Up to date - Auto-update enabled'
}
? 'Agent is outdated on this host'
: 'Agent is up to date'
}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
>
v{summary.host.agentVersion}
v{version()}
</span>
<Show when={agentOutdated}>
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
Update available
</span>
</Show>
</div>
)}
</Show>
<Show when={summary.host.intervalSeconds}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">
{summary.host.intervalSeconds}s
<Show when={agentOutdated}>
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
</span>
</Show>
</div>

View file

@ -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<string, unknown> | any;
}
type StatsFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null;
export const DockerHosts: Component<DockerHostsProps> = (props) => {
const navigate = useNavigate();
const { initialDataReceived, reconnecting, connected } = useWebSocket();
@ -40,8 +39,72 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
const [search, setSearch] = createSignal('');
const debouncedSearch = useDebouncedValue(search, 250);
const [selectedHostId, setSelectedHostId] = createSignal<string | null>(null);
const [statsFilter, setStatsFilter] = createSignal<StatsFilter>(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<DockerHostSummary[]>(() => {
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<DockerHostsProps> = (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<DockerHostsProps> = (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<DockerHostsProps> = (props) => {
setSearch={setSearch}
onReset={() => {
setSearch('');
setStatsFilter(null);
setSelectedHostId(null);
}}
searchInputRef={(el) => {
searchInputRef = el;
}}
/>
<Card padding="lg">
<DockerSummaryStatsBar
hosts={sortedHosts()}
onFilterChange={handleStatsFilterChange}
activeFilter={statsFilter()}
<Show when={hostSummaries().length > 0}>
<DockerHostSummaryTable
summaries={hostSummaries}
selectedHostId={selectedHostId}
onSelect={handleHostSelect}
/>
</Card>
</Show>
<DockerUnifiedTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
statsFilter={statsFilter()}
selectedHostId={selectedHostId}
/>
</>
}

File diff suppressed because it is too large Load diff

View file

@ -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<NodeSummaryTableProps> = (props) => {
>
<MetricBar
value={cpuPercentValue ?? 0}
label={`${cpuPercentValue}%`}
label={formatPercent(cpuPercentValue ?? 0)}
sublabel={
isPVE && node!.cpuInfo?.cores
? `${node!.cpuInfo.cores} cores`
@ -580,7 +580,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
>
<MetricBar
value={memoryPercentValue ?? 0}
label={`${memoryPercentValue}%`}
label={formatPercent(memoryPercentValue ?? 0)}
sublabel={
isPVE && node!.memory
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}`
@ -599,7 +599,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
>
<MetricBar
value={diskPercentValue ?? 0}
label={`${diskPercentValue}%`}
label={formatPercent(diskPercentValue ?? 0)}
sublabel={diskSublabel}
type="disk"
/>

View file

@ -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;

View file

@ -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';

View file

@ -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(),
}

View file

@ -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"

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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.

Binary file not shown.

View file

@ -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 &

View file

@ -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