Normalize docker agent version handling
This commit is contained in:
parent
7733d4b870
commit
f2acdd59af
20 changed files with 1600 additions and 1492 deletions
|
|
@ -45,12 +45,9 @@ export function MetricBar(props: MetricBarProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="metric-text">
|
<div class="metric-text w-full">
|
||||||
<div class="relative min-w-[96px] w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
||||||
<div
|
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
|
||||||
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="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 class="flex items-center gap-1 whitespace-nowrap px-0.5">
|
||||||
<span>{props.label}</span>
|
<span>{props.label}</span>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { DockerHost } from '@/types/api';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||||
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
||||||
import { formatUptime } from '@/utils/format';
|
import { formatPercent, formatUptime } from '@/utils/format';
|
||||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
|
|
||||||
export interface DockerHostSummary {
|
export interface DockerHostSummary {
|
||||||
|
|
@ -11,6 +11,8 @@ export interface DockerHostSummary {
|
||||||
cpuPercent: number;
|
cpuPercent: number;
|
||||||
memoryPercent: number;
|
memoryPercent: number;
|
||||||
memoryLabel?: string;
|
memoryLabel?: string;
|
||||||
|
diskPercent: number;
|
||||||
|
diskLabel?: string;
|
||||||
runningPercent: number;
|
runningPercent: number;
|
||||||
runningCount: number;
|
runningCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
@ -25,7 +27,7 @@ interface DockerHostSummaryTableProps {
|
||||||
onSelect: (hostId: string) => void;
|
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';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
|
@ -52,6 +54,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
return lastSeen;
|
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 sortedSummaries = createMemo(() => {
|
||||||
const list = [...props.summaries()];
|
const list = [...props.summaries()];
|
||||||
const key = sortKey();
|
const key = sortKey();
|
||||||
|
|
@ -75,6 +90,9 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
case 'memory':
|
case 'memory':
|
||||||
value = a.memoryPercent - b.memoryPercent;
|
value = a.memoryPercent - b.memoryPercent;
|
||||||
break;
|
break;
|
||||||
|
case 'disk':
|
||||||
|
value = a.diskPercent - b.diskPercent;
|
||||||
|
break;
|
||||||
case 'running':
|
case 'running':
|
||||||
value = a.runningPercent - b.runningPercent;
|
value = a.runningPercent - b.runningPercent;
|
||||||
break;
|
break;
|
||||||
|
|
@ -129,21 +147,27 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
>
|
>
|
||||||
Host {renderSortIndicator('name')}
|
Host {renderSortIndicator('name')}
|
||||||
</th>
|
</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
|
Status
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('cpu')}
|
||||||
>
|
>
|
||||||
CPU {renderSortIndicator('cpu')}
|
CPU {renderSortIndicator('cpu')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('memory')}
|
||||||
>
|
>
|
||||||
Memory {renderSortIndicator('memory')}
|
Memory {renderSortIndicator('memory')}
|
||||||
</th>
|
</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
|
<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"
|
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')}
|
onClick={() => handleSort('running')}
|
||||||
|
|
@ -151,19 +175,19 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
Containers {renderSortIndicator('running')}
|
Containers {renderSortIndicator('running')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('uptime')}
|
||||||
>
|
>
|
||||||
Uptime {renderSortIndicator('uptime')}
|
Uptime {renderSortIndicator('uptime')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('lastSeen')}
|
||||||
>
|
>
|
||||||
Last Update {renderSortIndicator('lastSeen')}
|
Last Update {renderSortIndicator('lastSeen')}
|
||||||
</th>
|
</th>
|
||||||
<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')}
|
onClick={() => handleSort('agent')}
|
||||||
>
|
>
|
||||||
Agent {renderSortIndicator('agent')}
|
Agent {renderSortIndicator('agent')}
|
||||||
|
|
@ -176,7 +200,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
const selected = props.selectedHostId() === summary.host.id;
|
const selected = props.selectedHostId() === summary.host.id;
|
||||||
const online = isHostOnline(summary.host);
|
const online = isHostOnline(summary.host);
|
||||||
const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—';
|
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 rowStyle = () => {
|
||||||
const styles: Record<string, string> = {};
|
const styles: Record<string, string> = {};
|
||||||
|
|
@ -217,7 +241,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
style={rowStyle()}
|
style={rowStyle()}
|
||||||
onClick={() => props.onSelect(summary.host.id)}
|
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">
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
|
<span class="font-medium text-[11px] text-gray-900 dark:text-gray-100">
|
||||||
{summary.host.displayName}
|
{summary.host.displayName}
|
||||||
|
|
@ -274,80 +298,107 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-top">
|
<td class="px-2 py-1 align-middle">
|
||||||
{renderDockerStatusBadge(summary.host.status)}
|
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
|
||||||
</td>
|
{renderDockerStatusBadge(summary.host.status)}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-2 py-1 align-top whitespace-nowrap sm:table-cell">
|
<td class="px-2 py-1 align-middle">
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
|
||||||
{uptimeLabel}
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
</span>
|
<MetricBar value={summary.cpuPercent} label={formatPercent(summary.cpuPercent)} type="cpu" />
|
||||||
</td>
|
</Show>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={summary.lastSeenAbsolute}>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{summary.lastSeenAbsolute}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-2 py-1 align-top sm:table-cell">
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex justify-center items-center h-full w-full max-w-[180px]">
|
||||||
<Show when={summary.host.agentVersion}>
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<div class="flex flex-col gap-0.5">
|
<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
|
<span
|
||||||
class={
|
class={
|
||||||
agentOutdated
|
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'
|
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
||||||
: '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-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
||||||
}
|
}
|
||||||
title={
|
title={`${
|
||||||
agentOutdated
|
agentOutdated
|
||||||
? 'Outdated - Update recommended'
|
? 'Agent is outdated on this host'
|
||||||
: 'Up to date - Auto-update enabled'
|
: 'Agent is up to date'
|
||||||
}
|
}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
||||||
>
|
>
|
||||||
v{summary.host.agentVersion}
|
v{version()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={agentOutdated}>
|
)}
|
||||||
<span class="text-[9px] text-yellow-600 dark:text-yellow-500 font-medium">
|
|
||||||
⚠ Update available
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={summary.host.intervalSeconds}>
|
<Show when={agentOutdated}>
|
||||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
<span class="text-[10px] text-yellow-600 dark:text-yellow-500 font-medium" title="Update recommended">
|
||||||
{summary.host.intervalSeconds}s
|
⚠
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import type { Component } from 'solid-js';
|
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 { useNavigate } from '@solidjs/router';
|
||||||
import type { DockerHost } from '@/types/api';
|
import type { DockerHost } from '@/types/api';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { EmptyState } from '@/components/shared/EmptyState';
|
import { EmptyState } from '@/components/shared/EmptyState';
|
||||||
import { DockerFilter } from './DockerFilter';
|
import { DockerFilter } from './DockerFilter';
|
||||||
import { DockerSummaryStatsBar } from './DockerSummaryStats';
|
import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSummaryTable';
|
||||||
import { DockerUnifiedTable } from './DockerUnifiedTable';
|
import { DockerUnifiedTable } from './DockerUnifiedTable';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
import { formatBytes, formatRelativeTime } from '@/utils/format';
|
||||||
|
|
||||||
interface DockerHostsProps {
|
interface DockerHostsProps {
|
||||||
hosts: DockerHost[];
|
hosts: DockerHost[];
|
||||||
activeAlerts?: Record<string, unknown> | any;
|
activeAlerts?: Record<string, unknown> | any;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatsFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null;
|
|
||||||
|
|
||||||
export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
const { initialDataReceived, reconnecting, connected } = useWebSocket();
|
||||||
|
|
@ -40,8 +39,72 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
|
|
||||||
const [search, setSearch] = createSignal('');
|
const [search, setSearch] = createSignal('');
|
||||||
const debouncedSearch = useDebouncedValue(search, 250);
|
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;
|
let searchInputRef: HTMLInputElement | undefined;
|
||||||
|
|
||||||
|
|
@ -52,12 +115,6 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const target = event.target as HTMLElement;
|
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) {
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -76,18 +133,18 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
onMount(() => document.addEventListener('keydown', handleKeyDown));
|
onMount(() => document.addEventListener('keydown', handleKeyDown));
|
||||||
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
|
||||||
|
|
||||||
const handleStatsFilterChange = (filter: StatsFilter) => {
|
createEffect(() => {
|
||||||
if (!filter) {
|
const hostId = selectedHostId();
|
||||||
setStatsFilter(null);
|
if (!hostId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!sortedHosts().some((host) => host.id === hostId)) {
|
||||||
|
setSelectedHostId(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setStatsFilter((current) => {
|
const handleHostSelect = (hostId: string) => {
|
||||||
if (current && current.type === filter.type && current.value === filter.value) {
|
setSelectedHostId((current) => (current === hostId ? null : hostId));
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return filter;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -127,25 +184,25 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setStatsFilter(null);
|
setSelectedHostId(null);
|
||||||
}}
|
}}
|
||||||
searchInputRef={(el) => {
|
searchInputRef={(el) => {
|
||||||
searchInputRef = el;
|
searchInputRef = el;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Show when={hostSummaries().length > 0}>
|
||||||
<DockerSummaryStatsBar
|
<DockerHostSummaryTable
|
||||||
hosts={sortedHosts()}
|
summaries={hostSummaries}
|
||||||
onFilterChange={handleStatsFilterChange}
|
selectedHostId={selectedHostId}
|
||||||
activeFilter={statsFilter()}
|
onSelect={handleHostSelect}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Show>
|
||||||
|
|
||||||
<DockerUnifiedTable
|
<DockerUnifiedTable
|
||||||
hosts={sortedHosts()}
|
hosts={sortedHosts()}
|
||||||
searchTerm={debouncedSearch()}
|
searchTerm={debouncedSearch()}
|
||||||
statsFilter={statsFilter()}
|
selectedHostId={selectedHostId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||||
import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api';
|
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 { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { getAlertStyles } from '@/utils/alerts';
|
import { getAlertStyles } from '@/utils/alerts';
|
||||||
|
|
@ -563,7 +563,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
>
|
>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={cpuPercentValue ?? 0}
|
value={cpuPercentValue ?? 0}
|
||||||
label={`${cpuPercentValue}%`}
|
label={formatPercent(cpuPercentValue ?? 0)}
|
||||||
sublabel={
|
sublabel={
|
||||||
isPVE && node!.cpuInfo?.cores
|
isPVE && node!.cpuInfo?.cores
|
||||||
? `${node!.cpuInfo.cores} cores`
|
? `${node!.cpuInfo.cores} cores`
|
||||||
|
|
@ -580,7 +580,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
>
|
>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={memoryPercentValue ?? 0}
|
value={memoryPercentValue ?? 0}
|
||||||
label={`${memoryPercentValue}%`}
|
label={formatPercent(memoryPercentValue ?? 0)}
|
||||||
sublabel={
|
sublabel={
|
||||||
isPVE && node!.memory
|
isPVE && node!.memory
|
||||||
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}`
|
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}`
|
||||||
|
|
@ -599,7 +599,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
>
|
>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={diskPercentValue ?? 0}
|
value={diskPercentValue ?? 0}
|
||||||
label={`${diskPercentValue}%`}
|
label={formatPercent(diskPercentValue ?? 0)}
|
||||||
sublabel={diskSublabel}
|
sublabel={diskSublabel}
|
||||||
type="disk"
|
type="disk"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,11 @@ export interface DockerHost {
|
||||||
cpus: number;
|
cpus: number;
|
||||||
totalMemoryBytes: number;
|
totalMemoryBytes: number;
|
||||||
uptimeSeconds: number;
|
uptimeSeconds: number;
|
||||||
|
cpuUsagePercent?: number;
|
||||||
|
loadAverage?: number[];
|
||||||
|
memory?: Memory;
|
||||||
|
disks?: Disk[];
|
||||||
|
networkInterfaces?: HostNetworkInterface[];
|
||||||
status: string;
|
status: string;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
intervalSeconds: number;
|
intervalSeconds: number;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,23 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
|
||||||
return `${formatBytes(bytesPerSecond, decimals)}/s`;
|
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 {
|
export function formatUptime(seconds: number): string {
|
||||||
if (!seconds || seconds < 0) return '0s';
|
if (!seconds || seconds < 0) return '0s';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
containertypes "github.com/docker/docker/api/types/container"
|
containertypes "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
|
||||||
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
@ -332,6 +333,13 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
|
||||||
|
|
||||||
uptime := readSystemUptime()
|
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
|
collectContainers := a.cfg.IncludeContainers
|
||||||
if !collectContainers && (a.cfg.IncludeServices || a.cfg.IncludeTasks) && !info.Swarm.ControlAvailable {
|
if !collectContainers && (a.cfg.IncludeServices || a.cfg.IncludeTasks) && !info.Swarm.ControlAvailable {
|
||||||
collectContainers = true
|
collectContainers = true
|
||||||
|
|
@ -365,6 +373,11 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
|
||||||
TotalCPU: info.NCPU,
|
TotalCPU: info.NCPU,
|
||||||
TotalMemoryBytes: info.MemTotal,
|
TotalMemoryBytes: info.MemTotal,
|
||||||
UptimeSeconds: uptime,
|
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(),
|
Timestamp: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,4 @@ package dockeragent
|
||||||
// overridden at build time via -ldflags for release artifacts. When building
|
// overridden at build time via -ldflags for release artifacts. When building
|
||||||
// from source without ldflags, it defaults to this development value.
|
// from source without ldflags, it defaults to this development value.
|
||||||
// Set to match deployed agents to prevent update loops in development.
|
// Set to match deployed agents to prevent update loops in development.
|
||||||
var Version = "v4.22.0-rc.6"
|
var Version = "v4.30.0"
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics"
|
||||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||||
"github.com/rs/zerolog"
|
"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"
|
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.
|
// Config controls the behaviour of the host agent.
|
||||||
|
|
@ -220,29 +215,9 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
uptime, _ := gohost.UptimeWithContext(collectCtx)
|
uptime, _ := gohost.UptimeWithContext(collectCtx)
|
||||||
loadAvg, _ := goload.AvgWithContext(collectCtx)
|
snapshot, err := hostmetrics.Collect(collectCtx)
|
||||||
cpuCount, _ := gocpu.CountsWithContext(collectCtx, true)
|
|
||||||
cpuUsage, err := a.calculateCPUUsage(collectCtx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Debug().Err(err).Msg("failed to compute cpu usage")
|
return agentshost.Report{}, fmt.Errorf("collect metrics: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
report := agentshost.Report{
|
report := agentshost.Report{
|
||||||
|
|
@ -263,23 +238,16 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||||
KernelVersion: a.kernelVersion,
|
KernelVersion: a.kernelVersion,
|
||||||
Architecture: a.architecture,
|
Architecture: a.architecture,
|
||||||
CPUModel: "",
|
CPUModel: "",
|
||||||
CPUCount: cpuCount,
|
CPUCount: snapshot.CPUCount,
|
||||||
UptimeSeconds: int64(uptime),
|
UptimeSeconds: int64(uptime),
|
||||||
LoadAverage: loadValues,
|
LoadAverage: append([]float64(nil), snapshot.LoadAverage...),
|
||||||
},
|
},
|
||||||
Metrics: agentshost.Metrics{
|
Metrics: agentshost.Metrics{
|
||||||
CPUUsagePercent: cpuUsage,
|
CPUUsagePercent: snapshot.CPUUsagePercent,
|
||||||
Memory: agentshost.MemoryMetric{
|
Memory: snapshot.Memory,
|
||||||
TotalBytes: int64(memStats.Total),
|
|
||||||
UsedBytes: int64(memStats.Used),
|
|
||||||
FreeBytes: int64(memStats.Free),
|
|
||||||
Usage: memStats.UsedPercent,
|
|
||||||
SwapTotal: int64(memStats.SwapTotal),
|
|
||||||
SwapUsed: swapUsed,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Disks: disks,
|
Disks: append([]agentshost.Disk(nil), snapshot.Disks...),
|
||||||
Network: network,
|
Network: append([]agentshost.NetworkInterface(nil), snapshot.Network...),
|
||||||
Sensors: agentshost.Sensors{},
|
Sensors: agentshost.Sensors{},
|
||||||
Tags: append([]string(nil), a.cfg.Tags...),
|
Tags: append([]string(nil), a.cfg.Tags...),
|
||||||
Timestamp: time.Now().UTC(),
|
Timestamp: time.Now().UTC(),
|
||||||
|
|
@ -288,123 +256,6 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) {
|
||||||
return report, nil
|
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 {
|
func (a *Agent) sendReport(ctx context.Context, report agentshost.Report) error {
|
||||||
payload, err := json.Marshal(report)
|
payload, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
191
internal/hostmetrics/collector.go
Normal file
191
internal/hostmetrics/collector.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1115,27 +1115,94 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
|
||||||
services, tasks = generateDockerServicesAndTasks(hostname, containers, now)
|
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{
|
host := models.DockerHost{
|
||||||
ID: hostID,
|
ID: hostID,
|
||||||
AgentID: fmt.Sprintf("agent-%s", randomHexString(6)),
|
AgentID: fmt.Sprintf("agent-%s", randomHexString(6)),
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
DisplayName: humanizeHostDisplayName(hostname),
|
DisplayName: humanizeHostDisplayName(hostname),
|
||||||
MachineID: randomHexString(32),
|
MachineID: randomHexString(32),
|
||||||
OS: dockerOperatingSystems[rand.Intn(len(dockerOperatingSystems))],
|
OS: dockerOperatingSystems[rand.Intn(len(dockerOperatingSystems))],
|
||||||
KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))],
|
KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))],
|
||||||
Architecture: dockerArchitectures[rand.Intn(len(dockerArchitectures))],
|
Architecture: dockerArchitectures[rand.Intn(len(dockerArchitectures))],
|
||||||
DockerVersion: dockerVersions[rand.Intn(len(dockerVersions))],
|
DockerVersion: dockerVersions[rand.Intn(len(dockerVersions))],
|
||||||
CPUs: cpus,
|
CPUs: cpus,
|
||||||
TotalMemoryBytes: totalMemoryBytes,
|
TotalMemoryBytes: totalMemoryBytes,
|
||||||
UptimeSeconds: uptime,
|
UptimeSeconds: uptime,
|
||||||
Status: status,
|
CPUUsage: cpuUsage,
|
||||||
LastSeen: lastSeen,
|
LoadAverage: loadAverage,
|
||||||
IntervalSeconds: interval,
|
Memory: memory,
|
||||||
AgentVersion: agentVersion,
|
Disks: disks,
|
||||||
Containers: containers,
|
NetworkInterfaces: networkInterfaces,
|
||||||
Services: services,
|
Status: status,
|
||||||
Tasks: tasks,
|
LastSeen: lastSeen,
|
||||||
Swarm: swarmInfo,
|
IntervalSeconds: interval,
|
||||||
|
AgentVersion: agentVersion,
|
||||||
|
Containers: containers,
|
||||||
|
Services: services,
|
||||||
|
Tasks: tasks,
|
||||||
|
Swarm: swarmInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts = append(hosts, host)
|
hosts = append(hosts, host)
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ func (d DockerHost) ToFrontend() DockerHostFrontend {
|
||||||
CPUs: d.CPUs,
|
CPUs: d.CPUs,
|
||||||
TotalMemoryBytes: d.TotalMemoryBytes,
|
TotalMemoryBytes: d.TotalMemoryBytes,
|
||||||
UptimeSeconds: d.UptimeSeconds,
|
UptimeSeconds: d.UptimeSeconds,
|
||||||
|
CPUUsagePercent: d.CPUUsage,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
LastSeen: d.LastSeen.Unix() * 1000,
|
LastSeen: d.LastSeen.Unix() * 1000,
|
||||||
IntervalSeconds: d.IntervalSeconds,
|
IntervalSeconds: d.IntervalSeconds,
|
||||||
|
|
@ -254,6 +255,24 @@ func (d DockerHost) ToFrontend() DockerHostFrontend {
|
||||||
h.Swarm = &sw
|
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 {
|
if d.Command != nil {
|
||||||
h.Command = toDockerHostCommandFrontend(*d.Command)
|
h.Command = toDockerHostCommandFrontend(*d.Command)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,33 +190,38 @@ type HostSensorSummary struct {
|
||||||
|
|
||||||
// DockerHost represents a Docker host reporting metrics via the external agent.
|
// DockerHost represents a Docker host reporting metrics via the external agent.
|
||||||
type DockerHost struct {
|
type DockerHost struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
AgentID string `json:"agentId"`
|
AgentID string `json:"agentId"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
MachineID string `json:"machineId,omitempty"`
|
MachineID string `json:"machineId,omitempty"`
|
||||||
OS string `json:"os,omitempty"`
|
OS string `json:"os,omitempty"`
|
||||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||||
Architecture string `json:"architecture,omitempty"`
|
Architecture string `json:"architecture,omitempty"`
|
||||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||||
CPUs int `json:"cpus"`
|
CPUs int `json:"cpus"`
|
||||||
TotalMemoryBytes int64 `json:"totalMemoryBytes"`
|
TotalMemoryBytes int64 `json:"totalMemoryBytes"`
|
||||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||||
Status string `json:"status"`
|
CPUUsage float64 `json:"cpuUsagePercent"`
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||||
IntervalSeconds int `json:"intervalSeconds"`
|
Memory Memory `json:"memory"`
|
||||||
AgentVersion string `json:"agentVersion,omitempty"`
|
Disks []Disk `json:"disks,omitempty"`
|
||||||
Containers []DockerContainer `json:"containers"`
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
Services []DockerService `json:"services,omitempty"`
|
Status string `json:"status"`
|
||||||
Tasks []DockerTask `json:"tasks,omitempty"`
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
|
IntervalSeconds int `json:"intervalSeconds"`
|
||||||
TokenID string `json:"tokenId,omitempty"`
|
AgentVersion string `json:"agentVersion,omitempty"`
|
||||||
TokenName string `json:"tokenName,omitempty"`
|
Containers []DockerContainer `json:"containers"`
|
||||||
TokenHint string `json:"tokenHint,omitempty"`
|
Services []DockerService `json:"services,omitempty"`
|
||||||
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
Tasks []DockerTask `json:"tasks,omitempty"`
|
||||||
Hidden bool `json:"hidden"`
|
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
|
||||||
PendingUninstall bool `json:"pendingUninstall"`
|
TokenID string `json:"tokenId,omitempty"`
|
||||||
Command *DockerHostCommandStatus `json:"command,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.
|
// DockerContainer represents the state of a Docker container on a monitored host.
|
||||||
|
|
|
||||||
|
|
@ -97,32 +97,37 @@ type ContainerFrontend struct {
|
||||||
|
|
||||||
// DockerHostFrontend represents a Docker host with frontend-friendly fields
|
// DockerHostFrontend represents a Docker host with frontend-friendly fields
|
||||||
type DockerHostFrontend struct {
|
type DockerHostFrontend struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
AgentID string `json:"agentId"`
|
AgentID string `json:"agentId"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
MachineID string `json:"machineId,omitempty"`
|
MachineID string `json:"machineId,omitempty"`
|
||||||
OS string `json:"os,omitempty"`
|
OS string `json:"os,omitempty"`
|
||||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||||
Architecture string `json:"architecture,omitempty"`
|
Architecture string `json:"architecture,omitempty"`
|
||||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||||
CPUs int `json:"cpus"`
|
CPUs int `json:"cpus"`
|
||||||
TotalMemoryBytes int64 `json:"totalMemoryBytes"`
|
TotalMemoryBytes int64 `json:"totalMemoryBytes"`
|
||||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||||
Status string `json:"status"`
|
CPUUsagePercent float64 `json:"cpuUsagePercent"`
|
||||||
LastSeen int64 `json:"lastSeen"`
|
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||||
IntervalSeconds int `json:"intervalSeconds"`
|
Memory *Memory `json:"memory,omitempty"`
|
||||||
AgentVersion string `json:"agentVersion,omitempty"`
|
Disks []Disk `json:"disks,omitempty"`
|
||||||
Containers []DockerContainerFrontend `json:"containers"`
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
Services []DockerServiceFrontend `json:"services,omitempty"`
|
Status string `json:"status"`
|
||||||
Tasks []DockerTaskFrontend `json:"tasks,omitempty"`
|
LastSeen int64 `json:"lastSeen"`
|
||||||
Swarm *DockerSwarmFrontend `json:"swarm,omitempty"`
|
IntervalSeconds int `json:"intervalSeconds"`
|
||||||
TokenID string `json:"tokenId,omitempty"`
|
AgentVersion string `json:"agentVersion,omitempty"`
|
||||||
TokenName string `json:"tokenName,omitempty"`
|
Containers []DockerContainerFrontend `json:"containers"`
|
||||||
TokenHint string `json:"tokenHint,omitempty"`
|
Services []DockerServiceFrontend `json:"services,omitempty"`
|
||||||
TokenLastUsedAt *int64 `json:"tokenLastUsedAt,omitempty"`
|
Tasks []DockerTaskFrontend `json:"tasks,omitempty"`
|
||||||
PendingUninstall bool `json:"pendingUninstall"`
|
Swarm *DockerSwarmFrontend `json:"swarm,omitempty"`
|
||||||
Command *DockerHostCommandFrontend `json:"command,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
|
// DockerContainerFrontend represents a Docker container for the frontend
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,18 @@ func convertDockerTasks(tasks []agentsdocker.Task) []models.DockerTask {
|
||||||
return result
|
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 {
|
func convertDockerSwarmInfo(info *agentsdocker.SwarmInfo) *models.DockerSwarmInfo {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -1390,27 +1402,80 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
||||||
tasks := convertDockerTasks(report.Tasks)
|
tasks := convertDockerTasks(report.Tasks)
|
||||||
swarmInfo := convertDockerSwarmInfo(report.Host.Swarm)
|
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{
|
host := models.DockerHost{
|
||||||
ID: identifier,
|
ID: identifier,
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
MachineID: strings.TrimSpace(report.Host.MachineID),
|
MachineID: strings.TrimSpace(report.Host.MachineID),
|
||||||
OS: report.Host.OS,
|
OS: report.Host.OS,
|
||||||
KernelVersion: report.Host.KernelVersion,
|
KernelVersion: report.Host.KernelVersion,
|
||||||
Architecture: report.Host.Architecture,
|
Architecture: report.Host.Architecture,
|
||||||
DockerVersion: report.Host.DockerVersion,
|
DockerVersion: report.Host.DockerVersion,
|
||||||
CPUs: report.Host.TotalCPU,
|
CPUs: report.Host.TotalCPU,
|
||||||
TotalMemoryBytes: report.Host.TotalMemoryBytes,
|
TotalMemoryBytes: report.Host.TotalMemoryBytes,
|
||||||
UptimeSeconds: report.Host.UptimeSeconds,
|
UptimeSeconds: report.Host.UptimeSeconds,
|
||||||
Status: "online",
|
CPUUsage: safeFloat(report.Host.CPUUsagePercent),
|
||||||
LastSeen: timestamp,
|
LoadAverage: loadAverage,
|
||||||
IntervalSeconds: report.Agent.IntervalSeconds,
|
Memory: memory,
|
||||||
AgentVersion: report.Agent.Version,
|
Disks: disks,
|
||||||
Containers: containers,
|
NetworkInterfaces: networkIfaces,
|
||||||
Services: services,
|
Status: "online",
|
||||||
Tasks: tasks,
|
LastSeen: timestamp,
|
||||||
Swarm: swarmInfo,
|
IntervalSeconds: report.Agent.IntervalSeconds,
|
||||||
|
AgentVersion: agentVersion,
|
||||||
|
Containers: containers,
|
||||||
|
Services: services,
|
||||||
|
Tasks: tasks,
|
||||||
|
Swarm: swarmInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenRecord != nil {
|
if tokenRecord != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
package dockeragent
|
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.
|
// Report represents a single heartbeat from the Docker agent to Pulse.
|
||||||
type Report struct {
|
type Report struct {
|
||||||
|
|
@ -21,17 +29,22 @@ type AgentInfo struct {
|
||||||
|
|
||||||
// HostInfo contains metadata about the Docker host where the agent runs.
|
// HostInfo contains metadata about the Docker host where the agent runs.
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
MachineID string `json:"machineId,omitempty"`
|
MachineID string `json:"machineId,omitempty"`
|
||||||
OS string `json:"os,omitempty"`
|
OS string `json:"os,omitempty"`
|
||||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||||
Architecture string `json:"architecture,omitempty"`
|
Architecture string `json:"architecture,omitempty"`
|
||||||
DockerVersion string `json:"dockerVersion,omitempty"`
|
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||||
TotalCPU int `json:"totalCpu,omitempty"`
|
TotalCPU int `json:"totalCpu,omitempty"`
|
||||||
TotalMemoryBytes int64 `json:"totalMemoryBytes,omitempty"`
|
TotalMemoryBytes int64 `json:"totalMemoryBytes,omitempty"`
|
||||||
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||||
Swarm *SwarmInfo `json:"swarm,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.
|
// Container captures the runtime state for a Docker container at report time.
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -175,30 +175,41 @@ FRONTEND_PORT=${PULSE_DEV_API_PORT}
|
||||||
PORT=${PULSE_DEV_API_PORT}
|
PORT=${PULSE_DEV_API_PORT}
|
||||||
export FRONTEND_PORT PULSE_DEV_API_PORT 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
|
if [[ ${PULSE_MOCK_MODE:-false} == "true" ]]; then
|
||||||
export PULSE_DATA_DIR=/opt/pulse/tmp/mock-data
|
export PULSE_DATA_DIR=/opt/pulse/tmp/mock-data
|
||||||
# Ensure mock data directory exists
|
|
||||||
mkdir -p "$PULSE_DATA_DIR"
|
mkdir -p "$PULSE_DATA_DIR"
|
||||||
echo "[hot-dev] Mock mode: Using isolated data directory: ${PULSE_DATA_DIR}"
|
echo "[hot-dev] Mock mode: Using isolated data directory: ${PULSE_DATA_DIR}"
|
||||||
else
|
else
|
||||||
DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config"
|
if [[ -n ${PULSE_DATA_DIR:-} ]]; then
|
||||||
mkdir -p "$DEV_CONFIG_DIR"
|
echo "[hot-dev] Using preconfigured data directory: ${PULSE_DATA_DIR}"
|
||||||
export PULSE_DATA_DIR="${DEV_CONFIG_DIR}"
|
elif [[ ${HOT_DEV_USE_PROD_DATA:-false} == "true" ]]; then
|
||||||
|
export PULSE_DATA_DIR=/etc/pulse
|
||||||
DEV_KEY_FILE="${DEV_CONFIG_DIR}/.encryption.key"
|
echo "[hot-dev] HOT_DEV_USE_PROD_DATA=true – using production data directory: ${PULSE_DATA_DIR}"
|
||||||
if [[ ! -f "${DEV_KEY_FILE}" ]]; then
|
else
|
||||||
openssl rand -hex 32 > "${DEV_KEY_FILE}"
|
DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config"
|
||||||
chmod 600 "${DEV_KEY_FILE}"
|
mkdir -p "$DEV_CONFIG_DIR"
|
||||||
echo "[hot-dev] Generated dev encryption key at ${DEV_KEY_FILE}"
|
export PULSE_DATA_DIR="${DEV_CONFIG_DIR}"
|
||||||
|
echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}"
|
||||||
fi
|
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
|
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
|
fi
|
||||||
|
|
||||||
echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
./pulse &
|
./pulse &
|
||||||
|
|
|
||||||
|
|
@ -877,6 +877,12 @@ if [[ "$UNINSTALL" != true ]]; then
|
||||||
systemctl stop pulse-docker-agent
|
systemctl stop pulse-docker-agent
|
||||||
fi
|
fi
|
||||||
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue