Expand Docker runtime inventory coverage

This commit is contained in:
rcourtman 2026-05-24 10:24:42 +01:00
parent f18502fc24
commit 89abed099c
64 changed files with 1511 additions and 66 deletions

View file

@ -86,7 +86,8 @@
"docker-volume",
"docker-network",
"docker-service",
"docker-task"
"docker-task",
"docker-swarm-node"
],
"support_floor": {
"setup": "supported",

View file

@ -208,7 +208,10 @@ physical host.
TrueNAS-managed apps.
5. Docker / Podman runtime inventory projects as `app-container`,
`docker-image`, `docker-volume`, `docker-network`, `docker-service`, and
`docker-task` under the `docker` runtime lens.
`docker-task` under the `docker` runtime lens. Docker Swarm node inventory
projects as `docker-swarm-node` when a Docker manager exposes the Swarm
Nodes API; Podman pod inventory remains out of scope until Pulse has a
libpod-native collector path instead of the Docker-compatible client.
6. Kubernetes projects as `k8s-cluster`, `k8s-node`, `pod`,
`k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`,
`k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`,
@ -324,7 +327,7 @@ Support floor fields are recorded in this order: `setup`, `visibility`,
| Platform | Surface kind | Governance | Readiness | Primary mode | Onboarding | Canonical projections | Support floor |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `agent` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `docker` | `runtime-lens` | `supported` | `supported` | `agent-backed` | `install-workspace` | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task`, `docker-swarm-node` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `kubernetes` | `platform` | `supported` | `supported` | `agent-backed` | `install-workspace` | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-namespace`, `k8s-configmap`, `k8s-serviceaccount`, `k8s-event` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` |
| `proxmox-pve` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=augmentation-only` |
| `proxmox-pbs` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pbs`, `storage` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
@ -383,7 +386,7 @@ or runtime platform fallbacks that are not declared in this document.
| Platform | Surface kind | Family | Primary mode | Optional augmentation | Canonical projections |
| ---------------- | ------------- | ------------------ | ------------ | ------------------------------------- | --------------------------------------------------------------------- |
| `agent` | platform | Pulse-managed host | agent-backed | none | `agent`, `storage`, `physical-disk` |
| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task` |
| `docker` | runtime-lens | container runtime | agent-backed | none | `agent`, `app-container`, `docker-image`, `docker-volume`, `docker-network`, `docker-service`, `docker-task`, `docker-swarm-node` |
| `kubernetes` | platform | cluster runtime | agent-backed | none | `k8s-cluster`, `k8s-node`, `pod`, `k8s-deployment`, `k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-service`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`, `k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-namespace`, `k8s-configmap`, `k8s-serviceaccount`, `k8s-event` |
| `proxmox-pve` | platform | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` |
| `proxmox-pbs` | platform | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` |

View file

@ -182,11 +182,15 @@ agent install target, a command-agent authority, or a reason to widen setup
tokens.
Docker / Podman native inventory and Kubernetes native API inventory follow
that same lifecycle boundary. Images, volumes, networks, Swarm tasks,
Kubernetes Services, controllers, EndpointSlices, NetworkPolicies, PV/PVC
records, StorageClasses, ConfigMaps, ServiceAccounts, ingresses, namespaces,
and events may appear as API/unified-resource evidence, but lifecycle surfaces
must not treat those child inventory rows as agent enrollment candidates,
install targets, fleet command authorities, or setup-token scopes.
Swarm nodes, engine storage-usage buckets, Kubernetes Services, controllers,
EndpointSlices, NetworkPolicies, PV/PVC records, StorageClasses, ConfigMaps,
ServiceAccounts, ingresses, namespaces, and events may appear as
API/unified-resource evidence, but lifecycle surfaces must not treat those
child inventory rows as agent enrollment candidates, install targets, fleet
command authorities, or setup-token scopes. Podman libpod pod records are not
part of that lifecycle-visible inventory until a libpod-native collector owns
them explicitly; Docker-compatible Podman container labels remain runtime
metadata only.
VMware vSphere `network` resources follow that same lifecycle boundary. A
vCenter connection may project networks into platform tables, resource
pickers, Assistant context, or monitored-system previews through the shared

View file

@ -118,14 +118,15 @@ checks, merge tenants, or become the source of truth for telemetry freshness.
The `/api/resources` type filter is the REST contract boundary for platform
page native inventory. It must accept canonical Docker / Podman runtime tokens
(`docker-image`, `docker-volume`, `docker-network`, `docker-task`) and
canonical Kubernetes API object tokens (`k8s-namespace`, `k8s-service`,
`k8s-replicaset`, `k8s-statefulset`, `k8s-daemonset`, `k8s-job`,
`k8s-cronjob`, `k8s-ingress`, `k8s-endpoint-slice`,
`k8s-network-policy`, `k8s-persistent-volume`,
(`docker-image`, `docker-volume`, `docker-network`, `docker-task`,
`docker-swarm-node`) and canonical Kubernetes API object tokens
(`k8s-namespace`, `k8s-service`, `k8s-replicaset`, `k8s-statefulset`,
`k8s-daemonset`, `k8s-job`, `k8s-cronjob`, `k8s-ingress`,
`k8s-endpoint-slice`, `k8s-network-policy`, `k8s-persistent-volume`,
`k8s-persistent-volume-claim`, `k8s-storage-class`, `k8s-configmap`,
`k8s-serviceaccount`, `k8s-event`) whenever
unified resources can publish those records. Unsupported legacy aliases should
`k8s-serviceaccount`, `k8s-event`) whenever unified resources can publish
those records. Docker node and Swarm node aliases may normalize to the
canonical `docker-swarm-node` token, but unsupported legacy aliases should
continue to fail closed instead of silently widening platform queries.
Discovery read endpoints are a canonical API payload boundary even when Pulse

View file

@ -169,7 +169,10 @@ Platform page subnavigation is a shared frontend primitive. Docker / Podman
and Kubernetes platform pages may add native API-backed sections, but the tabs
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
resource type presentation/reporting helpers rather than page-local tab shells,
alignment classes, or ad hoc report-category coercion.
alignment classes, or ad hoc report-category coercion. Docker / Podman native
subsections now include engine storage usage and Swarm node inventory where the
documented Docker APIs report those resources; Podman-only libpod pod inventory
must not be represented until the collector has a libpod-native source.
1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
The panel may own shell placement and local action layout, but

View file

@ -68,16 +68,21 @@ truth for live infrastructure data.
3. Add typed read access through `internal/unifiedresources/views.go`
4. Add unified supplemental ingest through `internal/monitoring/poll_providers.go`
5. Add or change container startup ownership/bootstrap behavior for hosted or managed Pulse runtime mounts through `docker-entrypoint.sh`
6. Add or change Docker Swarm manager task/service runtime collection through `internal/dockeragent/swarm.go`
6. Add or change Docker Swarm manager service, task, or node runtime collection through `internal/dockeragent/swarm.go`
Swarm node inventory is manager-sourced through the documented nodes API
when available and falls back to the local `system/info` Swarm node
metadata when a worker or non-manager runtime cannot list cluster nodes.
Manager-side list failures are warnings, not host-report failures.
7. Add or change Docker or Podman container stats compatibility and runtime metric semantics through `internal/dockeragent/collect.go`
Docker / Podman collection now owns native runtime inventory as well as
container metrics. It may collect image summaries, volume summaries,
network summaries, Swarm tasks, and daemon storage-usage buckets from the
documented runtime API, then publish those records through the Docker agent
report for unified-resource ingestion. Failures in image, volume, network,
or storage-usage collection are best-effort warnings and must not make the
whole host report fail when container/runtime health data is otherwise
usable.
network summaries, Swarm services, Swarm tasks, Swarm nodes, and daemon
storage-usage buckets from the documented runtime API, then publish those
records through the Docker agent report for unified-resource ingestion.
Failures in image, volume, network, node, or storage-usage collection are
best-effort warnings and must not make the whole host report fail when
container/runtime health data is otherwise usable. Podman libpod pods remain
outside this collector until a libpod-native collector owns that API shape.
8. Add or change Proxmox Ceph compatibility payload decoding through `pkg/proxmox/ceph.go`
9. Add or change Proxmox ZFS compatibility payload decoding and vdev-role normalization through `pkg/proxmox/zfs.go`
10. Add or change mock chart synthesis, seeded history continuity, or mock-owned

View file

@ -81,7 +81,7 @@ create, review, and approve cross-organization shares.
Organization resource shares may reference the canonical Docker / Podman
and Kubernetes inventory resource types exposed by `/api/resources`, but the
organization layer only validates the bounded resource-type vocabulary. It
must not reinterpret a Docker image/volume/network/task or Kubernetes
must not reinterpret a Docker image/volume/network/task/Swarm-node row or Kubernetes
Service/controller/storage/networking/config/event row, including
ReplicaSets, EndpointSlices, NetworkPolicies, StorageClasses, ConfigMaps, or
ServiceAccounts, as a broader organization role, agent enrollment authority,

View file

@ -163,6 +163,11 @@ volume rows or Kubernetes PV/PVC/StorageClass rows. Other Kubernetes native
inventory objects that flow through the shared resource decoder, such as
ConfigMaps and ServiceAccounts, remain platform configuration evidence only and
must not become storage/recovery ownership, restore scope, or secret material.
Docker engine `/system/df` storage-usage buckets are host-level runtime
capacity evidence for the Docker page and unified-resource Docker host facet;
they are not storage/recovery resources, recovery-point sources, or restore
entitlements. Docker Swarm node records are likewise runtime topology context,
not storage owners or recovery scope.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`

View file

@ -226,10 +226,18 @@ reintroduce false Swarm capability surfaces.
preserving service name, boot enablement, runtime state, and process IDs
through clone, merge, transport, and frontend decode paths.
Docker / Podman inventory extends that same canonical type contract beyond
containers and Swarm services. Runtime image, volume, network, task, and
storage-usage evidence must enter through `DockerData` and the typed Docker
resource records (`docker-image`, `docker-volume`, `docker-network`,
`docker-task`) rather than being inferred inside the container page.
containers and Swarm services. Runtime image, volume, network, task, Swarm
node, and storage-usage evidence must enter through `DockerData` and the
typed Docker resource records (`docker-image`, `docker-volume`,
`docker-network`, `docker-task`, `docker-swarm-node`) rather than being
inferred inside the container page. Swarm node records must preserve node
id, hostname, role, availability, state, manager reachability, manager
address, leader state, engine version, platform, resource capacity, labels,
and engine labels under the owning Docker host or Swarm cluster identity.
Host `/system/df` buckets remain Docker host facet data, not generic storage
resources. Podman libpod pod records must not be projected until a
libpod-native source can populate a native contract instead of deriving pods
from Docker-compatible container labels.
Kubernetes inventory likewise projects native API objects as first-class
unified resources: namespaces, Services, ReplicaSets, StatefulSets,
DaemonSets, Jobs, CronJobs, Ingresses, EndpointSlices, NetworkPolicies,
@ -1974,15 +1982,18 @@ Swarm only.`) instead of explanatory sentences, so mismatch fallback state
stays readable without turning support surfaces into inline documentation.
Docker platform subtabs now follow canonical resource evidence as well:
`Containers` is visible only when Docker/Podman app-container resources exist,
and `Swarm services` is visible only when canonical `docker-service` resources
exist. Inactive standalone Docker Swarm metadata is not a tab, host role, or
service-surface signal.
`Storage` is backed by host-level Docker/Podman `/system/df` usage buckets,
`Swarm services` is visible only when canonical `docker-service` resources
exist, and `Swarm nodes` is backed by canonical `docker-swarm-node` resources
from Docker manager node inventory. Inactive standalone Docker Swarm metadata
is not a tab, host role, or service-surface signal.
The frontend Docker facet contract covers both host runtime telemetry and
Swarm service projection. Host resources may expose runtime/version, OS,
temperature, uptime, container counts, update state, command metadata, and
Swarm local-state/control evidence through `ResourceDockerMeta`; Docker
platform pages must consume those canonical fields rather than keeping
page-local host-runtime shape aliases.
Swarm service/task/node projection. Host resources may expose runtime/version,
OS, temperature, uptime, container/image/volume/network/node counts, update
state, command metadata, engine storage usage buckets, and Swarm
local-state/control evidence through `ResourceDockerMeta`; Docker platform
pages must consume those canonical fields rather than keeping page-local
host-runtime shape aliases.
Canonical resources now also carry `platformScopes`, the normalized
platform-page membership list consumed by workload filters. `platformType`
remains the primary display/source family, while `platformScopes` captures

View file

@ -28,7 +28,7 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
type DockerInventoryVariant = 'images' | 'volumes' | 'networks' | 'tasks';
type DockerInventoryVariant = 'images' | 'volumes' | 'networks' | 'nodes' | 'tasks';
const textValue = (value: string | undefined): string => asTrimmedString(value) || '—';
const numberValue = (value: number | undefined): JSX.Element => (
@ -36,6 +36,11 @@ const numberValue = (value: number | undefined): JSX.Element => (
);
const byteValue = (value: number | undefined): string =>
typeof value === 'number' && value > 0 ? formatBytes(value) : '—';
const cpuValue = (nanoCpus: number | undefined): string => {
if (typeof nanoCpus !== 'number' || nanoCpus <= 0) return '—';
const cpus = nanoCpus / 1_000_000_000;
return cpus >= 10 ? `${Math.round(cpus)}` : cpus.toFixed(cpus % 1 === 0 ? 0 : 1);
};
const joinValues = (values: readonly (string | undefined)[] | undefined, empty = '—'): string => {
const joined = (values ?? [])
@ -54,6 +59,8 @@ const dockerTableTitle = (variant: DockerInventoryVariant, explicit?: string): s
return 'Volumes';
case 'networks':
return 'Networks';
case 'nodes':
return 'Swarm Nodes';
case 'tasks':
return 'Swarm Tasks';
}
@ -67,6 +74,8 @@ const searchPlaceholder = (variant: DockerInventoryVariant): string => {
return 'Search volumes';
case 'networks':
return 'Search networks';
case 'nodes':
return 'Search Swarm nodes';
case 'tasks':
return 'Search Swarm tasks';
}
@ -221,6 +230,35 @@ const DockerInventoryHeader: Component<{ variant: DockerInventoryVariant }> = (p
</TableRow>
);
}
if (props.variant === 'nodes') {
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>Node</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[10%]`}>Role</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[12%]`}>
Availability
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[14%]`}
>
Reachability
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[18%]`}
>
Engine
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[8%]`}
>
CPUs
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[16%]`}>
Memory
</TableHead>
</TableRow>
);
}
return (
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>Task</TableHead>
@ -353,6 +391,36 @@ const DockerInventoryRow: Component<{ resource: Resource; variant: DockerInvento
{textValue(props.resource.docker?.nodeName || props.resource.docker?.nodeId)}
</TableCell>
</Show>
<Show when={props.variant === 'nodes'}>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.nodeRole)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} text-base-content`}>
{textValue(props.resource.docker?.availability)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(
props.resource.docker?.leader
? 'leader'
: props.resource.docker?.managerReachability,
)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{textValue(props.resource.docker?.engineVersion || props.resource.docker?.runtimeVersion)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
{cpuValue(props.resource.docker?.nanoCpus)}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}>
{byteValue(props.resource.docker?.memoryBytes)}
</TableCell>
</Show>
</TableRow>
);
};

View file

@ -16,6 +16,7 @@ import { APP_CONTAINER_COLUMN_LABEL_OVERRIDES } from '@/features/platformPage/ap
import { DockerHostsTable } from './DockerHostsTable';
import { DockerInventoryTable } from './DockerInventoryTable';
import { DockerServicesTable } from './DockerServicesTable';
import { DockerStorageUsageTable } from './DockerStorageUsageTable';
import {
DOCKER_TAB_SPECS,
buildDockerPageModel,
@ -27,7 +28,7 @@ import {
} from './dockerPageModel';
const DOCKER_RESOURCE_QUERY =
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task';
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node';
const DOCKER_PLATFORM_FILTER = 'docker';
const DOCKER_WORKLOAD_FORCED_VIEW_MODE = 'app-container';
const DOCKER_WORKLOAD_DEFAULT_SORT_KEY = 'name';
@ -180,6 +181,24 @@ export function DockerPageSurface() {
emptyDescription="Networks appear here when the container runtime reports network inventory."
/>
</Show>
<Show when={activeTab() === 'storage'}>
<DockerStorageUsageTable
hosts={filteredHosts()}
sourceCount={model().hosts.length}
emptyIcon={dockerIcon()}
emptyTitle="No engine storage usage"
emptyDescription="Docker / Podman storage usage appears here when hosts report the engine disk-usage snapshot."
/>
</Show>
<Show when={activeTab() === 'swarm-nodes'}>
<DockerInventoryTable
resources={model().nodes}
variant="nodes"
emptyIcon={dockerIcon()}
emptyTitle="No Swarm nodes"
emptyDescription="Swarm nodes appear here when a Docker manager reports the cluster node inventory."
/>
</Show>
<Show when={activeTab() === 'services'}>
<DockerServicesTable
resources={model().services}

View file

@ -0,0 +1,200 @@
import { For, Show, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { formatBytes } from '@/utils/format';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import type { DockerStorageUsageMeta, Resource } from '@/types/resource';
const hasStorageBucket = (bucket?: DockerStorageUsageMeta): boolean =>
Boolean(
bucket &&
((bucket.totalCount ?? 0) > 0 ||
(bucket.activeCount ?? 0) > 0 ||
(bucket.totalSizeBytes ?? 0) > 0 ||
(bucket.reclaimableBytes ?? 0) > 0),
);
const hasEngineStorageUsage = (host: Resource): boolean =>
hasStorageBucket(host.docker?.imagesUsage) ||
hasStorageBucket(host.docker?.containersUsage) ||
hasStorageBucket(host.docker?.volumesUsage) ||
hasStorageBucket(host.docker?.buildCacheUsage);
const bucketValue = (bucket?: DockerStorageUsageMeta): JSX.Element => {
if (!hasStorageBucket(bucket)) return <span class="text-muted"></span>;
const totalSize = bucket?.totalSizeBytes ?? 0;
const reclaimable = bucket?.reclaimableBytes ?? 0;
const count = bucket?.totalCount ?? 0;
const active = bucket?.activeCount ?? 0;
return (
<span class="inline-flex min-w-0 flex-col items-end leading-tight">
<span class="tabular-nums text-base-content">{formatBytes(totalSize)}</span>
<span class="truncate text-[10px] text-muted">
{count} total, {active} active, {formatBytes(reclaimable)} reclaimable
</span>
</span>
);
};
export const DockerStorageUsageTable: Component<{
hosts: Resource[];
sourceCount?: number;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const storageHosts = () => props.hosts.filter(hasEngineStorageUsage);
const tableState = createPlatformTableFilterState({
resources: storageHosts,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const hasFilteredSourceRows = () => (props.sourceCount ?? props.hosts.length) > 0;
return (
<Show
when={storageHosts().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={
hasFilteredSourceRows() ? 'No engine storage usage reported' : props.emptyTitle
}
description={
hasFilteredSourceRows()
? 'Hosts are present, but none have reported the Docker / Podman disk-usage snapshot yet.'
: props.emptyDescription
}
/>
}
>
<div class="space-y-3">
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search storage usage"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="hosts"
/>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No storage rows match current filters"
description="Adjust the search or status filter to see more rows."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Engine Storage Usage" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1080px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
Host
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[19%]`}
>
Images
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[19%]`}
>
Containers
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[20%]`}
>
Volumes
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[20%]`}
>
Build Cache
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(host) => {
const name = () => asTrimmedString(host.name) || host.id;
const indicator = () => getSimpleStatusIndicator(host.status);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={host.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{bucketValue(host.docker?.imagesUsage)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{bucketValue(host.docker?.containersUsage)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
{bucketValue(host.docker?.volumesUsage)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content md:table-cell`}
>
{bucketValue(host.docker?.buildCacheUsage)}
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default DockerStorageUsageTable;

View file

@ -13,6 +13,10 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
useUnifiedResources: mocks.useUnifiedResources,
}));
vi.mock('@solidjs/router', () => ({
useLocation: () => ({ pathname: '/docker/overview' }),
}));
vi.mock('@/components/Workloads/useWorkloadsState', () => ({
useWorkloadsState: mocks.useWorkloadsState,
}));
@ -125,6 +129,11 @@ describe('DockerPageSurface', () => {
it('keeps host drawer ownership on the Docker hosts table instead of workload groups', () => {
render(() => <DockerPageSurface />);
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
expect.objectContaining({
query: expect.stringContaining('docker-swarm-node'),
}),
);
expect(mocks.useWorkloadsState).toHaveBeenCalledWith(
expect.objectContaining({
compactGroupHeaders: true,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
DOCKER_TAB_SPECS,
buildDockerContainerDefaultHiddenColumnIds,
buildDockerPageModel,
buildDockerWorkloadGroupLabelBadges,
@ -62,7 +63,7 @@ const makeDockerService = (overrides: Partial<Resource> = {}): Resource => ({
});
describe('dockerPageModel', () => {
it('buckets Docker hosts, containers, images, volumes, networks, tasks, and Swarm services from canonical resources', () => {
it('buckets Docker hosts, containers, images, volumes, networks, nodes, tasks, and Swarm services from canonical resources', () => {
const model = buildDockerPageModel([
makeResource({ id: 'docker-host-1', type: 'agent' }),
makeResource({
@ -74,6 +75,7 @@ describe('dockerPageModel', () => {
makeResource({ id: 'image-1', type: 'docker-image' }),
makeResource({ id: 'volume-1', type: 'docker-volume' }),
makeResource({ id: 'network-1', type: 'docker-network' }),
makeResource({ id: 'node-1', type: 'docker-swarm-node' }),
makeResource({ id: 'task-1', type: 'docker-task' }),
makeResource({
id: 'pve-node-1',
@ -88,12 +90,36 @@ describe('dockerPageModel', () => {
expect(model.images.map((resource) => resource.id)).toEqual(['image-1']);
expect(model.volumes.map((resource) => resource.id)).toEqual(['volume-1']);
expect(model.networks.map((resource) => resource.id)).toEqual(['network-1']);
expect(model.nodes.map((resource) => resource.id)).toEqual(['node-1']);
expect(model.tasks.map((resource) => resource.id)).toEqual(['task-1']);
expect(model.resources.map((resource) => resource.id).sort()).toEqual(
['ctr-1', 'docker-host-1', 'image-1', 'network-1', 'svc-1', 'task-1', 'volume-1'].sort(),
[
'ctr-1',
'docker-host-1',
'image-1',
'network-1',
'node-1',
'svc-1',
'task-1',
'volume-1',
].sort(),
);
});
it('declares bespoke Docker storage and Swarm node tabs', () => {
expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([
'overview',
'containers',
'images',
'volumes',
'networks',
'storage',
'swarm-nodes',
'services',
'tasks',
]);
});
it('excludes non-Docker hosts that share the agent type', () => {
const model = buildDockerPageModel([
makeResource({

View file

@ -15,6 +15,7 @@ const DOCKER_IMAGE_TYPES = new Set<ResourceType>(['docker-image']);
const DOCKER_VOLUME_TYPES = new Set<ResourceType>(['docker-volume']);
const DOCKER_NETWORK_TYPES = new Set<ResourceType>(['docker-network']);
const DOCKER_TASK_TYPES = new Set<ResourceType>(['docker-task']);
const DOCKER_SWARM_NODE_TYPES = new Set<ResourceType>(['docker-swarm-node']);
export type DockerPageTabId =
| 'overview'
@ -22,6 +23,8 @@ export type DockerPageTabId =
| 'images'
| 'volumes'
| 'networks'
| 'storage'
| 'swarm-nodes'
| 'services'
| 'tasks';
@ -35,6 +38,8 @@ export const DOCKER_TAB_SPECS: readonly {
{ id: 'images', label: 'Images', path: '/docker/images' },
{ id: 'volumes', label: 'Volumes', path: '/docker/volumes' },
{ id: 'networks', label: 'Networks', path: '/docker/networks' },
{ id: 'storage', label: 'Storage', path: '/docker/storage' },
{ id: 'swarm-nodes', label: 'Swarm Nodes', path: '/docker/swarm-nodes' },
{ id: 'services', label: 'Services', path: '/docker/services' },
{ id: 'tasks', label: 'Tasks', path: '/docker/tasks' },
] as const;
@ -76,6 +81,7 @@ export type DockerPageModel = {
images: Resource[];
volumes: Resource[];
networks: Resource[];
nodes: Resource[];
tasks: Resource[];
};
@ -147,6 +153,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
DOCKER_IMAGE_TYPES.has(resource.type) ||
DOCKER_VOLUME_TYPES.has(resource.type) ||
DOCKER_NETWORK_TYPES.has(resource.type) ||
DOCKER_SWARM_NODE_TYPES.has(resource.type) ||
DOCKER_TASK_TYPES.has(resource.type),
);
@ -160,6 +167,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
const images = dockerResources.filter((resource) => DOCKER_IMAGE_TYPES.has(resource.type));
const volumes = dockerResources.filter((resource) => DOCKER_VOLUME_TYPES.has(resource.type));
const networks = dockerResources.filter((resource) => DOCKER_NETWORK_TYPES.has(resource.type));
const nodes = dockerResources.filter((resource) => DOCKER_SWARM_NODE_TYPES.has(resource.type));
const tasks = dockerResources.filter((resource) => DOCKER_TASK_TYPES.has(resource.type));
return {
@ -170,6 +178,7 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
images,
volumes,
networks,
nodes,
tasks,
};
}
@ -197,6 +206,13 @@ const resourceSearchCandidates = (resource: Resource): Array<string | undefined>
resource.docker?.scope,
resource.docker?.taskId,
resource.docker?.serviceName,
resource.docker?.nodeId,
resource.docker?.nodeName,
resource.docker?.nodeRole,
resource.docker?.availability,
resource.docker?.managerReachability,
resource.docker?.engineVersion,
resource.docker?.address,
resource.docker?.currentState,
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),

View file

@ -525,6 +525,7 @@ const resolveType = (value?: string): ResourceType => {
case 'docker-volume':
case 'docker-network':
case 'docker-task':
case 'docker-swarm-node':
case 'k8s-cluster':
case 'k8s-node':
case 'k8s-deployment':
@ -575,6 +576,8 @@ const resolveType = (value?: string): ResourceType => {
return 'docker-network';
case 'docker-task':
return 'docker-task';
case 'docker-swarm-node':
return 'docker-swarm-node';
case 'k8s-deployment':
return 'k8s-deployment';
case 'k8s-replicaset':

View file

@ -66,6 +66,7 @@ describe('Resource Type Guards', () => {
const infrastructureTypes: ResourceType[] = [
'agent',
'docker-host',
'docker-swarm-node',
'k8s-node',
'k8s-cluster',
'network-endpoint',
@ -102,6 +103,7 @@ describe('Resource Type Guards', () => {
const nonWorkloadTypes: ResourceType[] = [
'agent',
'docker-host',
'docker-swarm-node',
'network-endpoint',
'network',
'storage',
@ -558,6 +560,7 @@ describe('Resource Interface', () => {
'docker-volume',
'docker-network',
'docker-task',
'docker-swarm-node',
'k8s-deployment',
'k8s-replicaset',
'k8s-service',

View file

@ -42,6 +42,7 @@ export type ResourceType =
| 'docker-volume' // Docker/Podman volume
| 'docker-network' // Docker/Podman network
| 'docker-task' // Docker Swarm task
| 'docker-swarm-node' // Docker Swarm node
| 'k8s-deployment' // Kubernetes deployment
| 'k8s-replicaset' // Kubernetes replica set
| 'k8s-service' // Kubernetes service
@ -584,6 +585,7 @@ export interface ResourceDockerMeta {
imageCount?: number;
volumeCount?: number;
networkCount?: number;
nodeCount?: number;
updatesAvailableCount?: number;
updatesLastCheckedAt?: string;
imagesUsage?: DockerStorageUsageMeta;
@ -649,6 +651,16 @@ export interface ResourceDockerMeta {
taskId?: string;
nodeId?: string;
nodeName?: string;
nodeRole?: string;
availability?: string;
address?: string;
managerReachability?: string;
managerAddress?: string;
leader?: boolean;
engineVersion?: string;
nanoCpus?: number;
memoryBytes?: number;
engineLabels?: Record<string, string>;
slot?: number;
desiredState?: string;
currentState?: string;
@ -1253,7 +1265,14 @@ export interface Resource {
* Helper type guards
*/
export function isInfrastructure(r: Resource): boolean {
return ['agent', 'docker-host', 'k8s-cluster', 'k8s-node', 'network-endpoint'].includes(r.type);
return [
'agent',
'docker-host',
'docker-swarm-node',
'k8s-cluster',
'k8s-node',
'network-endpoint',
].includes(r.type);
}
export function isWorkload(r: Resource): boolean {

View file

@ -16,6 +16,7 @@ describe('canonicalResourceTypes', () => {
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-volume');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-network');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-task');
expect(CANONICAL_RESOURCE_TYPES).toContain('docker-swarm-node');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-namespace');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-service');
expect(CANONICAL_RESOURCE_TYPES).toContain('k8s-replicaset');
@ -44,6 +45,7 @@ describe('canonicalResourceTypes', () => {
expect(isCanonicalResourceType('physical_disk')).toBe(true);
expect(isCanonicalResourceType('network-endpoint')).toBe(true);
expect(isCanonicalResourceType('docker-image')).toBe(true);
expect(isCanonicalResourceType('docker-swarm-node')).toBe(true);
expect(isCanonicalResourceType('k8s-event')).toBe(true);
expect(isCanonicalResourceType('host')).toBe(false);
expect(isCanonicalResourceType('lxc')).toBe(false);

View file

@ -23,6 +23,7 @@ describe('reportableResourceTypes', () => {
'docker-volume',
'docker-network',
'docker-task',
'docker-swarm-node',
'k8s-namespace',
'k8s-service',
'k8s-replicaset',

View file

@ -13,6 +13,7 @@ describe('toReportingResourceType', () => {
expect(toReportingResourceType('docker-volume')).toBe('storage');
expect(toReportingResourceType('docker-network')).toBe('network');
expect(toReportingResourceType('docker-task')).toBe('app-container');
expect(toReportingResourceType('docker-swarm-node')).toBe('app-container');
expect(toReportingResourceType('network-endpoint')).toBe('network-endpoint');
expect(toReportingResourceType('network-share')).toBe('network-share');
expect(toReportingResourceType('storage')).toBe('storage');

View file

@ -20,6 +20,9 @@ describe('resourceTypeCompat', () => {
expect(canonicalizeFrontendResourceType('docker-volume')).toBe('docker-volume');
expect(canonicalizeFrontendResourceType('docker-network')).toBe('docker-network');
expect(canonicalizeFrontendResourceType('docker-task')).toBe('docker-task');
expect(canonicalizeFrontendResourceType('docker-swarm-node')).toBe('docker-swarm-node');
expect(canonicalizeFrontendResourceType('docker-node')).toBe('docker-swarm-node');
expect(canonicalizeFrontendResourceType('swarm_node')).toBe('docker-swarm-node');
expect(canonicalizeFrontendResourceType('k8s-namespace')).toBe('k8s-namespace');
expect(canonicalizeFrontendResourceType('k8s-service')).toBe('k8s-service');
expect(canonicalizeFrontendResourceType('k8s-replicaset')).toBe('k8s-replicaset');

View file

@ -15,6 +15,7 @@ describe('resourceTypePresentation', () => {
expect(getResourceTypeLabel('docker-volume')).toBe('Volume');
expect(getResourceTypeLabel('docker-network')).toBe('Network');
expect(getResourceTypeLabel('docker-task')).toBe('Swarm Task');
expect(getResourceTypeLabel('docker-swarm-node')).toBe('Swarm Node');
expect(getResourceTypeLabel('k8s-service')).toBe('K8s Service');
expect(getResourceTypeLabel('k8s-replicaset')).toBe('ReplicaSet');
expect(getResourceTypeLabel('k8s-ingress')).toBe('Ingress');

View file

@ -16,6 +16,7 @@ export const CANONICAL_RESOURCE_TYPES = [
'docker-volume',
'docker-network',
'docker-task',
'docker-swarm-node',
'k8s-deployment',
'k8s-replicaset',
'k8s-service',

View file

@ -1,11 +1,11 @@
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
// Do not edit by hand.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a
// Source SHA256: af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a',
sha256: 'af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 2,
@ -82,6 +82,7 @@ export const PLATFORM_SUPPORT_MANIFEST = {
'docker-network',
'docker-service',
'docker-task',
'docker-swarm-node',
],
supportFloor: {
setup: 'supported',
@ -662,6 +663,7 @@ export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = {
'docker-network',
'docker-service',
'docker-task',
'docker-swarm-node',
],
kubernetes: [
'k8s-cluster',

View file

@ -27,6 +27,7 @@ export function toReportingResourceType(resourceType: ResourceType): ReportingRe
case 'docker-service':
case 'docker-image':
case 'docker-task':
case 'docker-swarm-node':
return 'app-container';
case 'docker-volume':
return 'storage';

View file

@ -13,6 +13,7 @@ export type CanonicalFrontendResourceType =
| 'docker-volume'
| 'docker-network'
| 'docker-task'
| 'docker-swarm-node'
| 'network'
| 'network-share'
| 'pbs'
@ -64,6 +65,15 @@ export const canonicalizeFrontendResourceType = (
case 'dockerhost':
case 'docker_host':
return 'docker-host';
case 'docker-node':
case 'docker_node':
case 'docker-nodes':
case 'docker_nodes':
case 'swarm-node':
case 'swarm_node':
case 'swarm-nodes':
case 'swarm_nodes':
return 'docker-swarm-node';
case 'k8s':
case 'kubernetes':
case 'k8s-pod':
@ -91,6 +101,7 @@ export const canonicalizeFrontendResourceType = (
case 'docker-volume':
case 'docker-network':
case 'docker-task':
case 'docker-swarm-node':
case 'network':
case 'network-share':
case 'pbs':

View file

@ -70,6 +70,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial<Record<ResourceType | string, Resource
label: 'Swarm Task',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'docker-swarm-node': {
label: 'Swarm Node',
badgeClasses: DEFAULT_BADGE_CLASSES,
},
'k8s-deployment': {
label: 'K8s Deployment',
badgeClasses: DEFAULT_BADGE_CLASSES,

View file

@ -11075,6 +11075,7 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test
"docker-volume",
"docker-network",
"docker-task",
"docker-swarm-node",
"k8s-namespace",
"k8s-service",
"k8s-replicaset",
@ -11103,6 +11104,7 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test
unifiedresources.ResourceTypeDockerVolume,
unifiedresources.ResourceTypeDockerNetwork,
unifiedresources.ResourceTypeDockerTask,
unifiedresources.ResourceTypeDockerSwarmNode,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypeK8sService,
unifiedresources.ResourceTypeK8sReplicaSet,

View file

@ -46,6 +46,7 @@ var supportedOrganizationShareResourceTypes = map[string]struct{}{
"docker-volume": {},
"docker-network": {},
"docker-task": {},
"docker-swarm-node": {},
"k8s-deployment": {},
"k8s-replicaset": {},
"k8s-service": {},

View file

@ -1240,6 +1240,7 @@ func TestIsUnsupportedOrganizationShareResourceType(t *testing.T) {
{name: "docker volume supported", in: "docker-volume", want: false},
{name: "docker network supported", in: "docker-network", want: false},
{name: "docker task supported", in: "docker-task", want: false},
{name: "docker swarm node supported", in: "docker-swarm-node", want: false},
{name: "kubernetes namespace supported", in: "k8s-namespace", want: false},
{name: "kubernetes service supported", in: "k8s-service", want: false},
{name: "kubernetes replicaset supported", in: "k8s-replicaset", want: false},

View file

@ -1865,6 +1865,8 @@ func resourceTypeFilterAdapter(token string) []unified.ResourceType {
return []unified.ResourceType{unified.ResourceTypeDockerNetwork}
case "docker-task", "docker-tasks":
return []unified.ResourceType{unified.ResourceTypeDockerTask}
case "docker-swarm-node", "docker-swarm-nodes", "docker-node", "docker-nodes", "swarm-node", "swarm-nodes":
return []unified.ResourceType{unified.ResourceTypeDockerSwarmNode}
case "vm", "vms":
return []unified.ResourceType{unified.ResourceTypeVM}
case "system-container", "system-containers", "oci-container":

View file

@ -191,6 +191,8 @@ func TestParseResourceTypesNodeAlias(t *testing.T) {
{name: "docker volume", input: "docker-volume", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerVolume: {}}},
{name: "docker network", input: "docker-network", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerNetwork: {}}},
{name: "docker task", input: "docker-task", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerTask: {}}},
{name: "docker swarm node", input: "docker-swarm-node", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerSwarmNode: {}}},
{name: "docker node alias", input: "docker-node", want: map[unified.ResourceType]struct{}{unified.ResourceTypeDockerSwarmNode: {}}},
{name: "k8s namespace", input: "k8s-namespace", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sNamespace: {}}},
{name: "k8s service", input: "k8s-service", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sService: {}}},
{name: "k8s statefulset", input: "k8s-statefulset", want: map[unified.ResourceType]struct{}{unified.ResourceTypeK8sStatefulSet: {}}},

View file

@ -13,6 +13,7 @@ import (
containertypes "github.com/moby/moby/api/types/container"
imagetypes "github.com/moby/moby/api/types/image"
networktypes "github.com/moby/moby/api/types/network"
swarmtypes "github.com/moby/moby/api/types/swarm"
systemtypes "github.com/moby/moby/api/types/system"
volumetypes "github.com/moby/moby/api/types/volume"
dockerclient "github.com/moby/moby/client"
@ -134,6 +135,81 @@ func TestCollectDockerNativeInventory(t *testing.T) {
}
}
func TestCollectSwarmNodesMapsManagerInventory(t *testing.T) {
createdAt := time.Date(2026, 5, 24, 8, 15, 0, 0, time.UTC)
updatedAt := createdAt.Add(2 * time.Minute)
agent := &Agent{
logger: zerolog.Nop(),
docker: &fakeDockerClient{
nodeListFn: func(context.Context, dockerNodeListOptions) ([]swarmtypes.Node, error) {
return []swarmtypes.Node{{
ID: " node-1 ",
Spec: swarmtypes.NodeSpec{
Role: swarmtypes.NodeRoleManager,
Availability: swarmtypes.NodeAvailabilityActive,
Annotations: swarmtypes.Annotations{
Labels: map[string]string{"zone": "rack-a"},
},
},
Description: swarmtypes.NodeDescription{
Hostname: " manager-1 ",
Platform: swarmtypes.Platform{
OS: "linux",
Architecture: "amd64",
},
Resources: swarmtypes.Resources{
NanoCPUs: 8_000_000_000,
MemoryBytes: 32 * 1024 * 1024 * 1024,
},
Engine: swarmtypes.EngineDescription{
EngineVersion: "27.5.1",
Labels: map[string]string{"engine": "primary"},
},
},
Status: swarmtypes.NodeStatus{
State: swarmtypes.NodeStateReady,
Message: "ready",
Addr: "192.0.2.10",
},
ManagerStatus: &swarmtypes.ManagerStatus{
Leader: true,
Reachability: swarmtypes.ReachabilityReachable,
Addr: "192.0.2.10:2377",
},
Meta: swarmtypes.Meta{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
}}, nil
},
},
}
nodes, err := agent.collectSwarmNodes(context.Background())
if err != nil {
t.Fatalf("collectSwarmNodes: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected one node, got %+v", nodes)
}
node := nodes[0]
if node.ID != "node-1" || node.Hostname != "manager-1" || node.Role != string(swarmtypes.NodeRoleManager) {
t.Fatalf("unexpected node identity: %+v", node)
}
if node.ManagerReachability != string(swarmtypes.ReachabilityReachable) || node.ManagerAddress != "192.0.2.10:2377" || !node.Leader {
t.Fatalf("expected manager status to be preserved, got %+v", node)
}
if node.EngineVersion != "27.5.1" || node.NanoCPUs != 8_000_000_000 || node.MemoryBytes == 0 {
t.Fatalf("expected engine capacity to be preserved, got %+v", node)
}
if node.Labels["zone"] != "rack-a" || node.EngineLabels["engine"] != "primary" {
t.Fatalf("expected node labels to be preserved, got labels=%+v engine=%+v", node.Labels, node.EngineLabels)
}
if !node.CreatedAt.Equal(createdAt) || node.UpdatedAt == nil || !node.UpdatedAt.Equal(updatedAt) {
t.Fatalf("expected timestamps to be preserved, got %+v", node)
}
}
func TestCollectContainer(t *testing.T) {
logger := zerolog.Nop()

View file

@ -119,7 +119,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
}
}
services, tasks, swarmInfo := a.collectSwarmData(ctx, info, containers)
services, tasks, nodes, swarmInfo := a.collectSwarmData(ctx, info, containers)
diskUsageResult, storageUsage, err := a.collectStorageUsage(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect Docker storage usage")
@ -196,6 +196,9 @@ func (a *Agent) buildReport(ctx context.Context) (agentsdocker.Report, error) {
if a.cfg.IncludeTasks && len(tasks) > 0 {
report.Tasks = tasks
}
if len(nodes) > 0 {
report.Nodes = nodes
}
if storageUsage != nil {
report.StorageUsage = storageUsage
}

View file

@ -118,6 +118,10 @@ type dockerTaskListOptions struct {
Filters dockerFilters
}
type dockerNodeListOptions struct {
Filters dockerFilters
}
type dockerClient interface {
Info(ctx context.Context) (systemtypes.Info, error)
DaemonHost() string
@ -138,6 +142,7 @@ type dockerClient interface {
DiskUsage(ctx context.Context, options dockerDiskUsageOptions) (client.DiskUsageResult, error)
ServiceList(ctx context.Context, options dockerServiceListOptions) ([]swarmtypes.Service, error)
TaskList(ctx context.Context, options dockerTaskListOptions) ([]swarmtypes.Task, error)
NodeList(ctx context.Context, options dockerNodeListOptions) ([]swarmtypes.Node, error)
ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
Close() error
}
@ -310,6 +315,14 @@ func (m *mobyDockerClient) TaskList(ctx context.Context, options dockerTaskListO
return result.Items, nil
}
func (m *mobyDockerClient) NodeList(ctx context.Context, options dockerNodeListOptions) ([]swarmtypes.Node, error) {
result, err := m.Client.NodeList(ctx, client.NodeListOptions{Filters: options.Filters.toClientFilters()})
if err != nil {
return nil, err
}
return result.Items, nil
}
func (m *mobyDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
result, err := m.Client.ImageInspect(ctx, imageID)
if err != nil {

View file

@ -66,13 +66,13 @@ func hasReportableSwarmInfo(info systemtypes.Info) bool {
strings.TrimSpace(swarm.Cluster.Spec.Annotations.Name) != ""))
}
func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, containers []agentsdocker.Container) ([]agentsdocker.Service, []agentsdocker.Task, *agentsdocker.SwarmInfo) {
func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, containers []agentsdocker.Container) ([]agentsdocker.Service, []agentsdocker.Task, []agentsdocker.Node, *agentsdocker.SwarmInfo) {
if !a.supportsSwarm {
return nil, nil, nil
return nil, nil, nil, nil
}
if !hasReportableSwarmInfo(info) {
return nil, nil, nil
return nil, nil, nil, nil
}
scope := a.resolvedSwarmScope(info)
@ -101,11 +101,24 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con
includeTasks := a.cfg.IncludeTasks
if info.Swarm.LocalNodeState != swarmtypes.LocalNodeStateActive {
return nil, nil, swarmInfo
return nil, nil, nil, swarmInfo
}
var services []agentsdocker.Service
var tasks []agentsdocker.Task
var nodes []agentsdocker.Node
if info.Swarm.ControlAvailable {
managerNodes, err := a.collectSwarmNodes(ctx)
if err != nil {
a.logger.Warn().Err(err).Msg("failed to collect swarm nodes from manager")
} else {
nodes = managerNodes
}
}
if len(nodes) == 0 {
nodes = deriveLocalSwarmNode(info, firstNonEmptyString(a.hostName, info.Name))
}
containerIndex := buildContainerIndex(containers)
@ -158,6 +171,15 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con
})
}
if len(nodes) > 0 {
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].Hostname == nodes[j].Hostname {
return nodes[i].ID < nodes[j].ID
}
return nodes[i].Hostname < nodes[j].Hostname
})
}
swarmInfo.Scope = effectiveScope
if !includeServices {
@ -167,7 +189,30 @@ func (a *Agent) collectSwarmData(ctx context.Context, info systemtypes.Info, con
tasks = nil
}
return services, tasks, swarmInfo
return services, tasks, nodes, swarmInfo
}
func (a *Agent) collectSwarmNodes(ctx context.Context) ([]agentsdocker.Node, error) {
if a == nil || a.docker == nil {
return nil, nil
}
nodeList, err := dockerCallWithRetry(ctx, dockerSwarmListCallTimeout, func(callCtx context.Context) ([]swarmtypes.Node, error) {
return a.docker.NodeList(callCtx, dockerNodeListOptions{})
})
if err != nil {
return nil, annotateDockerConnectionError(err)
}
nodes := make([]agentsdocker.Node, 0, len(nodeList))
for i := range nodeList {
node := mapSwarmNode(&nodeList[i])
if strings.TrimSpace(node.ID) == "" && strings.TrimSpace(node.Hostname) == "" {
continue
}
nodes = append(nodes, node)
}
return nodes, nil
}
func (a *Agent) collectSwarmDataFromManager(ctx context.Context, info systemtypes.Info, scope string, containers map[string]agentsdocker.Container, includeServices, includeTasks bool) ([]agentsdocker.Service, []agentsdocker.Task, error) {
@ -382,6 +427,65 @@ func mapSwarmTask(task *swarmtypes.Task, svc *swarmtypes.Service, containers map
return result
}
func mapSwarmNode(node *swarmtypes.Node) agentsdocker.Node {
result := agentsdocker.Node{
ID: strings.TrimSpace(node.ID),
Hostname: strings.TrimSpace(node.Description.Hostname),
Role: strings.TrimSpace(string(node.Spec.Role)),
Availability: strings.TrimSpace(string(node.Spec.Availability)),
State: strings.TrimSpace(string(node.Status.State)),
Message: strings.TrimSpace(node.Status.Message),
Address: strings.TrimSpace(node.Status.Addr),
EngineVersion: strings.TrimSpace(node.Description.Engine.EngineVersion),
OS: strings.TrimSpace(node.Description.Platform.OS),
Architecture: strings.TrimSpace(node.Description.Platform.Architecture),
NanoCPUs: node.Description.Resources.NanoCPUs,
MemoryBytes: node.Description.Resources.MemoryBytes,
Labels: copyStringMap(node.Spec.Annotations.Labels),
EngineLabels: copyStringMap(node.Description.Engine.Labels),
CreatedAt: node.Meta.CreatedAt,
}
if !node.Meta.UpdatedAt.IsZero() {
updated := node.Meta.UpdatedAt
result.UpdatedAt = &updated
}
if node.ManagerStatus != nil {
result.ManagerReachability = strings.TrimSpace(string(node.ManagerStatus.Reachability))
result.ManagerAddress = strings.TrimSpace(node.ManagerStatus.Addr)
result.Leader = node.ManagerStatus.Leader
}
return result
}
func deriveLocalSwarmNode(info systemtypes.Info, hostname string) []agentsdocker.Node {
nodeID := strings.TrimSpace(info.Swarm.NodeID)
if nodeID == "" {
return nil
}
role := "worker"
if info.Swarm.ControlAvailable {
role = "manager"
}
node := agentsdocker.Node{
ID: nodeID,
Hostname: strings.TrimSpace(hostname),
Role: role,
State: string(info.Swarm.LocalNodeState),
Message: strings.TrimSpace(info.Swarm.Error),
EngineVersion: strings.TrimSpace(info.ServerVersion),
OS: strings.TrimSpace(info.OSType),
Architecture: strings.TrimSpace(info.Architecture),
NanoCPUs: int64(info.NCPU) * 1_000_000_000,
MemoryBytes: info.MemTotal,
}
return []agentsdocker.Node{node}
}
func serviceMode(mode swarmtypes.ServiceMode) string {
switch {
case mode.Global != nil:
@ -604,6 +708,15 @@ func copyStringMap(source map[string]string) map[string]string {
return result
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func isTaskCompletedState(state string) bool {
switch strings.ToLower(state) {
case "completed", "complete", "shutdown", "failed", "rejected":

View file

@ -278,19 +278,83 @@ func TestCollectSwarmDataFromManager(t *testing.T) {
})
}
func TestMapSwarmNode(t *testing.T) {
createdAt := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC)
updatedAt := createdAt.Add(5 * time.Minute)
node := &swarmtypes.Node{
ID: "node-1",
Spec: swarmtypes.NodeSpec{
Annotations: swarmtypes.Annotations{
Labels: map[string]string{"zone": "rack-a"},
},
Role: swarmtypes.NodeRoleManager,
Availability: swarmtypes.NodeAvailabilityActive,
},
Description: swarmtypes.NodeDescription{
Hostname: "manager-1",
Platform: swarmtypes.Platform{
OS: "linux",
Architecture: "amd64",
},
Resources: swarmtypes.Resources{
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
},
Engine: swarmtypes.EngineDescription{
EngineVersion: "27.5.1",
Labels: map[string]string{"engine": "primary"},
},
},
Status: swarmtypes.NodeStatus{
State: swarmtypes.NodeStateReady,
Message: "ready",
Addr: "192.0.2.10",
},
ManagerStatus: &swarmtypes.ManagerStatus{
Leader: true,
Reachability: swarmtypes.ReachabilityReachable,
Addr: "192.0.2.10:2377",
},
Meta: swarmtypes.Meta{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
}
got := mapSwarmNode(node)
if got.ID != "node-1" || got.Hostname != "manager-1" || got.Role != string(swarmtypes.NodeRoleManager) {
t.Fatalf("unexpected node identity: %+v", got)
}
if got.Availability != string(swarmtypes.NodeAvailabilityActive) || got.State != string(swarmtypes.NodeStateReady) {
t.Fatalf("unexpected node state: %+v", got)
}
if got.ManagerReachability != string(swarmtypes.ReachabilityReachable) || !got.Leader {
t.Fatalf("expected manager reachability and leader flag, got %+v", got)
}
if got.EngineVersion != "27.5.1" || got.NanoCPUs != 4_000_000_000 || got.MemoryBytes == 0 {
t.Fatalf("expected engine resources, got %+v", got)
}
if got.Labels["zone"] != "rack-a" || got.EngineLabels["engine"] != "primary" {
t.Fatalf("expected node labels to be mapped, got labels=%+v engine=%+v", got.Labels, got.EngineLabels)
}
if got.CreatedAt.IsZero() || got.UpdatedAt == nil || got.UpdatedAt.IsZero() {
t.Fatalf("expected node timestamps, got %+v", got)
}
}
func TestCollectSwarmData(t *testing.T) {
t.Run("unsupported swarm returns nils", func(t *testing.T) {
agent := &Agent{supportsSwarm: false}
services, tasks, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
if services != nil || tasks != nil || info != nil {
services, tasks, nodes, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
if services != nil || tasks != nil || nodes != nil || info != nil {
t.Fatal("expected nil outputs when swarm unsupported")
}
})
t.Run("empty swarm info returns nil", func(t *testing.T) {
agent := &Agent{supportsSwarm: true}
services, tasks, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
if services != nil || tasks != nil || info != nil {
services, tasks, nodes, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
if services != nil || tasks != nil || nodes != nil || info != nil {
t.Fatal("expected nil outputs for empty swarm info")
}
})
@ -303,8 +367,8 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if services != nil || tasks != nil || swarmInfo != nil {
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if services != nil || tasks != nil || nodes != nil || swarmInfo != nil {
t.Fatal("expected standalone inactive swarm state to be ignored")
}
})
@ -318,8 +382,8 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if services != nil || tasks != nil {
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if services != nil || tasks != nil || nodes != nil {
t.Fatal("expected nil services/tasks for pending swarm")
}
if swarmInfo == nil || swarmInfo.NodeID != "node1" {
@ -371,10 +435,13 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
if len(tasks) != 1 || len(services) != 1 {
t.Fatalf("expected derived tasks/services, got %d/%d", len(tasks), len(services))
}
if len(nodes) != 1 || nodes[0].ID != "node1" {
t.Fatalf("expected derived local node, got %+v", nodes)
}
if swarmInfo == nil || swarmInfo.Scope != swarmScopeNode {
t.Fatalf("expected effective scope node, got %+v", swarmInfo)
}
@ -389,6 +456,26 @@ func TestCollectSwarmData(t *testing.T) {
SwarmScope: swarmScopeCluster,
},
docker: &fakeDockerClient{
nodeListFn: func(context.Context, dockerNodeListOptions) ([]swarmtypes.Node, error) {
return []swarmtypes.Node{{
ID: "node-manager",
Spec: swarmtypes.NodeSpec{
Role: swarmtypes.NodeRoleManager,
Availability: swarmtypes.NodeAvailabilityActive,
},
Description: swarmtypes.NodeDescription{
Hostname: "manager-1",
Engine: swarmtypes.EngineDescription{
EngineVersion: "27.5.1",
},
},
Status: swarmtypes.NodeStatus{State: swarmtypes.NodeStateReady},
ManagerStatus: &swarmtypes.ManagerStatus{
Leader: true,
Reachability: swarmtypes.ReachabilityReachable,
},
}}, nil
},
serviceListFn: func(context.Context, dockerServiceListOptions) ([]swarmtypes.Service, error) {
return []swarmtypes.Service{
{ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "zeta"}}},
@ -412,10 +499,13 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if len(tasks) != 2 || len(services) != 2 {
t.Fatalf("expected manager tasks/services, got %d/%d", len(tasks), len(services))
}
if len(nodes) != 1 || nodes[0].ID != "node-manager" || nodes[0].Hostname != "manager-1" {
t.Fatalf("expected manager node inventory, got %+v", nodes)
}
if swarmInfo == nil || swarmInfo.Scope != swarmScopeCluster {
t.Fatalf("unexpected swarm info: %+v", swarmInfo)
}
@ -453,13 +543,16 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
if services != nil {
t.Fatal("expected services to be nil when disabled")
}
if len(tasks) != 1 {
t.Fatalf("expected tasks to be returned")
}
if len(nodes) != 1 {
t.Fatalf("expected local swarm node to be returned")
}
if swarmInfo == nil {
t.Fatalf("expected swarm info")
}
@ -489,10 +582,13 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if services != nil || tasks != nil {
t.Fatal("expected nil services/tasks when disabled")
}
if len(nodes) != 1 {
t.Fatalf("expected local swarm node to be returned")
}
if swarmInfo == nil || swarmInfo.ClusterID != "cluster1" || swarmInfo.ClusterName != "prod" {
t.Fatalf("expected cluster info to be populated")
}
@ -533,10 +629,13 @@ func TestCollectSwarmData(t *testing.T) {
},
}
services, tasks, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
services, tasks, nodes, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
if swarmInfo == nil {
t.Fatalf("expected swarm info")
}
if len(nodes) != 1 {
t.Fatalf("expected local swarm node to be returned")
}
if len(tasks) != 4 || len(services) != 3 {
t.Fatalf("expected tasks and services")
}

View file

@ -38,6 +38,7 @@ type fakeDockerClient struct {
diskUsageFn func(ctx context.Context, opts dockerDiskUsageOptions) (client.DiskUsageResult, error)
serviceListFn func(ctx context.Context, opts dockerServiceListOptions) ([]swarmtypes.Service, error)
taskListFn func(ctx context.Context, opts dockerTaskListOptions) ([]swarmtypes.Task, error)
nodeListFn func(ctx context.Context, opts dockerNodeListOptions) ([]swarmtypes.Node, error)
imageInspectWithRawFn func(ctx context.Context, imageID string) (image.InspectResponse, []byte, error)
closeFn func() error
}
@ -172,6 +173,13 @@ func (f *fakeDockerClient) TaskList(ctx context.Context, opts dockerTaskListOpti
return f.taskListFn(ctx, opts)
}
func (f *fakeDockerClient) NodeList(ctx context.Context, opts dockerNodeListOptions) ([]swarmtypes.Node, error) {
if f.nodeListFn == nil {
return nil, nil
}
return f.nodeListFn(ctx, opts)
}
func (f *fakeDockerClient) ImageInspectWithRaw(ctx context.Context, imageID string) (image.InspectResponse, []byte, error) {
if f.imageInspectWithRawFn == nil {
// Return empty response with no RepoDigests by default (simulates locally built image)

View file

@ -267,6 +267,13 @@ func (d DockerHost) ToFrontend() DockerHostFrontend {
}
}
if len(d.Nodes) > 0 {
h.Nodes = make([]DockerNodeFrontend, len(d.Nodes))
for i, node := range d.Nodes {
h.Nodes[i] = node.ToFrontend()
}
}
if d.Swarm != nil {
sw := d.Swarm.ToFrontend()
h.Swarm = &sw
@ -723,6 +730,44 @@ func (t DockerTask) ToFrontend() DockerTaskFrontend {
return task
}
// ToFrontend converts a DockerNode to DockerNodeFrontend.
func (n DockerNode) ToFrontend() DockerNodeFrontend {
node := DockerNodeFrontend{
ID: n.ID,
Hostname: n.Hostname,
Role: n.Role,
Availability: n.Availability,
State: n.State,
Message: n.Message,
Address: n.Address,
ManagerReachability: n.ManagerReachability,
ManagerAddress: n.ManagerAddress,
Leader: n.Leader,
EngineVersion: n.EngineVersion,
OS: n.OS,
Architecture: n.Architecture,
NanoCPUs: n.NanoCPUs,
MemoryBytes: n.MemoryBytes,
Labels: make(map[string]string, len(n.Labels)),
EngineLabels: make(map[string]string, len(n.EngineLabels)),
}
for key, value := range n.Labels {
node.Labels[key] = value
}
for key, value := range n.EngineLabels {
node.EngineLabels[key] = value
}
if !n.CreatedAt.IsZero() {
ts := n.CreatedAt.Unix() * 1000
node.CreatedAt = &ts
}
if n.UpdatedAt != nil && !n.UpdatedAt.IsZero() {
ts := n.UpdatedAt.Unix() * 1000
node.UpdatedAt = &ts
}
return node
}
// ToFrontend converts DockerSwarmInfo to DockerSwarmFrontend.
func (s DockerSwarmInfo) ToFrontend() DockerSwarmFrontend {
return DockerSwarmFrontend(s)

View file

@ -865,6 +865,44 @@ func TestVMToFrontend_TagsJoinedCorrectly(t *testing.T) {
}
}
func TestDockerNodeToFrontend(t *testing.T) {
now := time.Now()
updatedAt := now.Add(5 * time.Minute)
node := DockerNode{
ID: "node-1",
Hostname: "manager-1",
Role: "manager",
Availability: "active",
State: "ready",
ManagerReachability: "reachable",
ManagerAddress: "192.0.2.10:2377",
Leader: true,
EngineVersion: "27.5.1",
OS: "linux",
Architecture: "amd64",
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: now,
UpdatedAt: &updatedAt,
}
frontend := node.ToFrontend()
if frontend.ID != node.ID || frontend.Hostname != node.Hostname || frontend.Role != node.Role {
t.Fatalf("unexpected frontend node identity: %+v", frontend)
}
if frontend.ManagerReachability != node.ManagerReachability || !frontend.Leader {
t.Fatalf("unexpected frontend manager metadata: %+v", frontend)
}
if frontend.CreatedAt == nil || *frontend.CreatedAt != now.Unix()*1000 {
t.Fatalf("CreatedAt = %v, want %d", frontend.CreatedAt, now.Unix()*1000)
}
if frontend.UpdatedAt == nil || *frontend.UpdatedAt != updatedAt.Unix()*1000 {
t.Fatalf("UpdatedAt = %v, want %d", frontend.UpdatedAt, updatedAt.Unix()*1000)
}
}
func TestDockerHostToFrontend(t *testing.T) {
now := time.Now()
tokenLastUsed := now.Add(-1 * time.Hour)
@ -901,6 +939,9 @@ func TestDockerHostToFrontend(t *testing.T) {
Containers: []DockerContainer{
{ID: "ct-1", Name: "nginx", State: "running"},
},
Nodes: []DockerNode{
{ID: "node-1", Hostname: "manager-1", Role: "manager"},
},
}
frontend := host.ToFrontend()
@ -944,6 +985,9 @@ func TestDockerHostToFrontend(t *testing.T) {
if len(frontend.Containers) != 1 {
t.Errorf("Containers length = %d, want 1", len(frontend.Containers))
}
if len(frontend.Nodes) != 1 {
t.Errorf("Nodes length = %d, want 1", len(frontend.Nodes))
}
}
func TestDockerHostToFrontend_EmptyDisplayName(t *testing.T) {

View file

@ -457,6 +457,21 @@ func cloneDockerTasks(src []DockerTask) []DockerTask {
return dest
}
func cloneDockerNodes(src []DockerNode) []DockerNode {
if len(src) == 0 {
return nil
}
dest := make([]DockerNode, len(src))
for i, node := range src {
nodeCopy := node
nodeCopy.Labels = cloneStringMap(node.Labels)
nodeCopy.EngineLabels = cloneStringMap(node.EngineLabels)
nodeCopy.UpdatedAt = cloneTimePtr(node.UpdatedAt)
dest[i] = nodeCopy.NormalizeCollections()
}
return dest
}
func cloneDockerSwarmInfo(src *DockerSwarmInfo) *DockerSwarmInfo {
if src == nil {
return nil
@ -498,6 +513,7 @@ func cloneDockerHost(src DockerHost) DockerHost {
dest.Networks = cloneDockerNetworks(src.Networks)
dest.Services = cloneDockerServices(src.Services)
dest.Tasks = cloneDockerTasks(src.Tasks)
dest.Nodes = cloneDockerNodes(src.Nodes)
dest.StorageUsage = cloneDockerStorageUsage(src.StorageUsage)
dest.Swarm = cloneDockerSwarmInfo(src.Swarm)
dest.Security = cloneDockerHostSecurity(src.Security)

View file

@ -658,6 +658,7 @@ type DockerHost struct {
Networks []DockerNetwork `json:"networks,omitempty"`
Services []DockerService `json:"services,omitempty"`
Tasks []DockerTask `json:"tasks,omitempty"`
Nodes []DockerNode `json:"nodes,omitempty"`
StorageUsage *DockerStorageUsage `json:"storageUsage,omitempty"`
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
Security *DockerHostSecurity `json:"security,omitempty"`
@ -724,6 +725,12 @@ func (h DockerHost) NormalizeCollections() DockerHost {
if h.Tasks == nil {
h.Tasks = []DockerTask{}
}
if h.Nodes == nil {
h.Nodes = []DockerNode{}
}
for i := range h.Nodes {
h.Nodes[i] = h.Nodes[i].NormalizeCollections()
}
if h.Security != nil {
security := h.Security.NormalizeCollections()
h.Security = &security
@ -1658,6 +1665,39 @@ type DockerTask struct {
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
// DockerNode summarises a Docker Swarm node.
type DockerNode struct {
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
Role string `json:"role,omitempty"`
Availability string `json:"availability,omitempty"`
State string `json:"state,omitempty"`
Message string `json:"message,omitempty"`
Address string `json:"address,omitempty"`
ManagerReachability string `json:"managerReachability,omitempty"`
ManagerAddress string `json:"managerAddress,omitempty"`
Leader bool `json:"leader,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
OS string `json:"os,omitempty"`
Architecture string `json:"architecture,omitempty"`
NanoCPUs int64 `json:"nanoCpus,omitempty"`
MemoryBytes int64 `json:"memoryBytes,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
EngineLabels map[string]string `json:"engineLabels,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
func (n DockerNode) NormalizeCollections() DockerNode {
if n.Labels == nil {
n.Labels = map[string]string{}
}
if n.EngineLabels == nil {
n.EngineLabels = map[string]string{}
}
return n
}
// DockerSwarmInfo captures node-level swarm metadata.
type DockerSwarmInfo struct {
NodeID string `json:"nodeId,omitempty"`

View file

@ -168,6 +168,7 @@ type DockerHostFrontend struct {
Containers []DockerContainerFrontend `json:"containers"`
Services []DockerServiceFrontend `json:"services"`
Tasks []DockerTaskFrontend `json:"tasks"`
Nodes []DockerNodeFrontend `json:"nodes"`
Swarm *DockerSwarmFrontend `json:"swarm,omitempty"`
Security *DockerHostSecurityFrontend `json:"security,omitempty"`
TokenID string `json:"tokenId,omitempty"`
@ -197,6 +198,9 @@ func (h DockerHostFrontend) NormalizeCollections() DockerHostFrontend {
if h.Tasks == nil {
h.Tasks = []DockerTaskFrontend{}
}
if h.Nodes == nil {
h.Nodes = []DockerNodeFrontend{}
}
for i := range h.Containers {
h.Containers[i] = h.Containers[i].NormalizeCollections()
}
@ -582,6 +586,29 @@ type DockerTaskFrontend struct {
CompletedAt *int64 `json:"completedAt,omitempty"`
}
// DockerNodeFrontend represents a Swarm node.
type DockerNodeFrontend struct {
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
Role string `json:"role,omitempty"`
Availability string `json:"availability,omitempty"`
State string `json:"state,omitempty"`
Message string `json:"message,omitempty"`
Address string `json:"address,omitempty"`
ManagerReachability string `json:"managerReachability,omitempty"`
ManagerAddress string `json:"managerAddress,omitempty"`
Leader bool `json:"leader,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
OS string `json:"os,omitempty"`
Architecture string `json:"architecture,omitempty"`
NanoCPUs int64 `json:"nanoCpus,omitempty"`
MemoryBytes int64 `json:"memoryBytes,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
EngineLabels map[string]string `json:"engineLabels,omitempty"`
CreatedAt *int64 `json:"createdAt,omitempty"`
UpdatedAt *int64 `json:"updatedAt,omitempty"`
}
// DockerSwarmFrontend summarises node-level swarm details.
type DockerSwarmFrontend struct {
NodeID string `json:"nodeId,omitempty"`

View file

@ -120,6 +120,29 @@ func TestDockerInventoryConvertersPreserveNativeRuntimeFields(t *testing.T) {
if usage == nil || usage.Images.TotalCount != 3 || usage.Images.ReclaimableBytes != 512 {
t.Fatalf("unexpected storage usage conversion: %+v", usage)
}
nodes := convertDockerNodes([]agentsdocker.Node{{
ID: " node-manager ",
Hostname: " manager-1 ",
Role: " manager ",
Availability: " active ",
State: " ready ",
Address: " 192.0.2.10 ",
ManagerReachability: " reachable ",
ManagerAddress: " 192.0.2.10:2377 ",
Leader: true,
EngineVersion: " 27.5.1 ",
OS: " linux ",
Architecture: " amd64 ",
NanoCPUs: 8_000_000_000,
MemoryBytes: 32 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: createdAt,
}})
if len(nodes) != 1 || nodes[0].ID != "node-manager" || nodes[0].ManagerReachability != "reachable" || nodes[0].EngineLabels["engine"] != "primary" {
t.Fatalf("unexpected swarm node conversion: %+v", nodes)
}
}
func TestPVEBackupPermissionWarningsPreserveTokenACLRepair(t *testing.T) {

View file

@ -1242,6 +1242,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
networks := convertDockerNetworks(report.Networks)
services := convertDockerServices(report.Services)
tasks := convertDockerTasks(report.Tasks)
nodes := convertDockerNodes(report.Nodes)
storageUsage := convertDockerStorageUsage(report.StorageUsage)
swarmInfo := convertDockerSwarmInfo(report.Host.Swarm)
security := deriveDockerHostSecurity(report.Host.Security, runtime)
@ -1350,6 +1351,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
Networks: networks,
Services: services,
Tasks: tasks,
Nodes: nodes,
StorageUsage: storageUsage,
Swarm: swarmInfo,
Security: security,

View file

@ -390,6 +390,43 @@ func convertDockerTasks(tasks []agentsdocker.Task) []models.DockerTask {
return result
}
func convertDockerNodes(nodes []agentsdocker.Node) []models.DockerNode {
if len(nodes) == 0 {
return nil
}
result := make([]models.DockerNode, 0, len(nodes))
for _, node := range nodes {
modelNode := models.DockerNode{
ID: strings.TrimSpace(node.ID),
Hostname: strings.TrimSpace(node.Hostname),
Role: strings.TrimSpace(node.Role),
Availability: strings.TrimSpace(node.Availability),
State: strings.TrimSpace(node.State),
Message: strings.TrimSpace(node.Message),
Address: strings.TrimSpace(node.Address),
ManagerReachability: strings.TrimSpace(node.ManagerReachability),
ManagerAddress: strings.TrimSpace(node.ManagerAddress),
Leader: node.Leader,
EngineVersion: strings.TrimSpace(node.EngineVersion),
OS: strings.TrimSpace(node.OS),
Architecture: strings.TrimSpace(node.Architecture),
NanoCPUs: node.NanoCPUs,
MemoryBytes: node.MemoryBytes,
Labels: cloneStringMap(node.Labels),
EngineLabels: cloneStringMap(node.EngineLabels),
CreatedAt: node.CreatedAt,
}
if node.UpdatedAt != nil && !node.UpdatedAt.IsZero() {
updated := *node.UpdatedAt
modelNode.UpdatedAt = &updated
}
result = append(result, modelNode.NormalizeCollections())
}
return result
}
func normalizeAgentVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" {

View file

@ -403,6 +403,52 @@ func TestConvertDockerServices_LabelIsolation(t *testing.T) {
}
}
// --- convertDockerNodes ---
func TestConvertDockerNodes_WithManagerFields(t *testing.T) {
now := time.Now().UTC()
updated := now.Add(5 * time.Minute)
src := []agentsdocker.Node{
{
ID: " node-1 ",
Hostname: " manager-1 ",
Role: " manager ",
Availability: " active ",
State: " ready ",
Message: " ready ",
Address: " 192.0.2.10 ",
ManagerReachability: " reachable ",
ManagerAddress: " 192.0.2.10:2377 ",
Leader: true,
EngineVersion: " 27.5.1 ",
OS: " linux ",
Architecture: " amd64 ",
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: now,
UpdatedAt: &updated,
},
}
result := convertDockerNodes(src)
if len(result) != 1 {
t.Fatalf("expected one converted node, got %+v", result)
}
node := result[0]
if node.ID != "node-1" || node.Hostname != "manager-1" || node.Role != "manager" {
t.Fatalf("unexpected converted identity: %+v", node)
}
if node.ManagerReachability != "reachable" || node.ManagerAddress != "192.0.2.10:2377" || !node.Leader {
t.Fatalf("unexpected manager metadata: %+v", node)
}
result[0].Labels["mutated"] = "yes"
if _, ok := src[0].Labels["mutated"]; ok {
t.Fatal("node labels should be cloned")
}
}
// --- extractSnapshotName ---
func TestExtractSnapshotName_WithAt(t *testing.T) {

View file

@ -168,6 +168,77 @@ func TestApplyDockerReport_RecreatedContainerAgentIDKeepsTokenBinding(t *testing
}
}
func TestApplyDockerReportPreservesDockerSwarmNodes(t *testing.T) {
monitor := newTestMonitor(t)
now := time.Now().UTC()
report := agentsdocker.Report{
Agent: agentsdocker.AgentInfo{
ID: "docker-agent-1",
Version: "1.0.0",
IntervalSeconds: 30,
},
Host: agentsdocker.HostInfo{
Hostname: "docker-host",
MachineID: "machine-docker-1",
Runtime: "docker",
RuntimeVersion: "27.5.1",
Swarm: &agentsdocker.SwarmInfo{
NodeID: "node-manager",
NodeRole: "manager",
LocalState: "active",
ControlAvailable: true,
ClusterID: "cluster-1",
ClusterName: "prod-swarm",
Scope: "cluster",
},
},
Nodes: []agentsdocker.Node{{
ID: " node-manager ",
Hostname: " manager-1 ",
Role: " manager ",
Availability: " active ",
State: " ready ",
Address: " 192.0.2.10 ",
ManagerReachability: " reachable ",
ManagerAddress: " 192.0.2.10:2377 ",
Leader: true,
EngineVersion: " 27.5.1 ",
OS: " linux ",
Architecture: " amd64 ",
NanoCPUs: 8_000_000_000,
MemoryBytes: 32 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: now.Add(-time.Hour),
UpdatedAt: &now,
}},
Timestamp: now,
}
host, err := monitor.ApplyDockerReport(report, nil)
if err != nil {
t.Fatalf("ApplyDockerReport: %v", err)
}
if len(host.Nodes) != 1 {
t.Fatalf("expected host node inventory, got %+v", host.Nodes)
}
node := host.Nodes[0]
if node.ID != "node-manager" || node.Hostname != "manager-1" || node.Role != "manager" {
t.Fatalf("unexpected host node identity: %+v", node)
}
if !node.Leader || node.ManagerReachability != "reachable" || node.EngineLabels["engine"] != "primary" {
t.Fatalf("expected manager metadata to be preserved, got %+v", node)
}
snapshot := monitor.state.GetSnapshot()
if len(snapshot.DockerHosts) != 1 || len(snapshot.DockerHosts[0].Nodes) != 1 {
t.Fatalf("expected state snapshot to preserve nodes, got %+v", snapshot.DockerHosts)
}
if got := snapshot.DockerHosts[0].Nodes[0].ID; got != "node-manager" {
t.Fatalf("snapshot node id = %q, want node-manager", got)
}
}
func TestEvaluateHostAgentsTriggersOfflineAlert(t *testing.T) {
t.Helper()

View file

@ -1,7 +1,7 @@
// Code generated by scripts/release_control/generate_platform_support_backend_module.py.
// DO NOT EDIT.
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
// Source SHA256: 165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a
// Source SHA256: af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368
package platformsupport
@ -13,7 +13,7 @@ const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MA
// ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce
// this projection.
const ManifestSourceSHA256 = "165f38fd8ec06299a966bba865ec28a29b3e9d4d99790e2e65da73da9ca8f45a"
const ManifestSourceSHA256 = "af152a63e78b121f52eb33fcdb7fb945ca5054435a4e4fb150973715b5489368"
// ManifestSchemaVersion is the schema_version field of the manifest.
const ManifestSchemaVersion = 2

View file

@ -403,6 +403,23 @@ func TestDockerNativeInventoryAdapters(t *testing.T) {
if taskResource.Type != ResourceTypeDockerTask || taskResource.Name != "api.2" || taskResource.Status != StatusOnline || taskIdentity.Hostnames[0] != "api.2" {
t.Fatalf("unexpected task resource: resource=%+v identity=%+v", taskResource, taskIdentity)
}
nodeResource, nodeIdentity := resourceFromDockerSwarmNode(models.DockerNode{
ID: "node-1",
Hostname: "manager-1",
Role: "manager",
Availability: "active",
State: "ready",
ManagerReachability: "reachable",
Leader: true,
EngineVersion: "27.5.1",
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
}, host)
if nodeResource.Type != ResourceTypeDockerSwarmNode || nodeResource.Docker == nil || nodeResource.Docker.NodeID != "node-1" || nodeResource.Docker.NodeRole != "manager" || nodeIdentity.Hostnames[0] != "manager-1" {
t.Fatalf("unexpected swarm node resource: resource=%+v identity=%+v", nodeResource, nodeIdentity)
}
}
func TestKubernetesNativeInventoryAdapters(t *testing.T) {

View file

@ -1154,6 +1154,7 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity)
ImageCount: len(host.Images),
VolumeCount: len(host.Volumes),
NetworkCount: len(host.Networks),
NodeCount: len(host.Nodes),
UpdatesAvailableCount: updatesAvailableCount,
UpdatesLastCheckedAt: updatesLastCheckedPtr,
TokenID: host.TokenID,
@ -1173,6 +1174,7 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity)
NetworksRaw: append([]models.DockerNetwork(nil), host.Networks...),
Services: append([]models.DockerService(nil), host.Services...),
Tasks: append([]models.DockerTask(nil), host.Tasks...),
Nodes: append([]models.DockerNode(nil), host.Nodes...),
}
if host.StorageUsage != nil {
docker.ImagesUsage = dockerStorageUsageMeta(host.StorageUsage.Images)
@ -2186,6 +2188,54 @@ func resourceFromDockerTask(task models.DockerTask, host models.DockerHost) (Res
return resource, identity
}
func resourceFromDockerSwarmNode(node models.DockerNode, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(node.Hostname), shortDigest(node.ID), node.ID)
clusterName := dockerSwarmClusterKeyFromMeta(convertSwarm(host.Swarm))
labels := cloneLabelMap(node.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: strings.TrimSpace(host.Hostname),
NodeID: strings.TrimSpace(node.ID),
NodeName: strings.TrimSpace(node.Hostname),
NodeRole: strings.TrimSpace(node.Role),
Availability: strings.TrimSpace(node.Availability),
CurrentState: strings.TrimSpace(node.State),
Message: strings.TrimSpace(node.Message),
Address: strings.TrimSpace(node.Address),
ManagerReachability: strings.TrimSpace(node.ManagerReachability),
ManagerAddress: strings.TrimSpace(node.ManagerAddress),
Leader: node.Leader,
EngineVersion: strings.TrimSpace(node.EngineVersion),
RuntimeVersion: strings.TrimSpace(node.EngineVersion),
OS: strings.TrimSpace(node.OS),
Architecture: strings.TrimSpace(node.Architecture),
NanoCPUs: node.NanoCPUs,
MemoryBytes: node.MemoryBytes,
Runtime: "docker",
Labels: labels,
EngineLabels: cloneLabelMap(node.EngineLabels),
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerSwarmNode,
Technology: "docker",
Name: name,
Status: statusFromDockerSwarmNode(node),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(node.ID)}),
}
if clusterName != "" {
identity.ClusterName = clusterName
identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+name))
}
return resource, identity
}
func firstDockerImageReference(image models.DockerImage) string {
for _, candidate := range image.RepoTags {
candidate = strings.TrimSpace(candidate)

View file

@ -37,6 +37,9 @@ func canonicalPrimaryID(resource Resource) string {
if identity := formatTargetIdentity(resource.DiscoveryTarget); identity != "" {
return identity
}
if identity := canonicalDockerSwarmNodePrimaryID(resource); identity != "" {
return identity
}
if runtimeID := strings.TrimSpace(canonicalDockerRuntimeID(resource)); runtimeID != "" {
return "docker-host:" + runtimeID
}
@ -81,6 +84,8 @@ func canonicalAliases(resource Resource, primaryID, platformID, hostname string)
targetResourceID(resource.MetricsTarget),
targetAgentID(resource.DiscoveryTarget),
targetResourceID(resource.DiscoveryTarget),
canonicalDockerNodeID(resource),
canonicalDockerNodeName(resource),
canonicalDockerRuntimeID(resource),
canonicalKubernetesClusterID(resource),
canonicalAgentID(resource),
@ -223,6 +228,35 @@ func canonicalDockerRuntimeID(resource Resource) string {
return ""
}
func canonicalDockerSwarmNodePrimaryID(resource Resource) string {
if CanonicalResourceType(resource.Type) != ResourceTypeDockerSwarmNode || resource.Docker == nil {
return ""
}
nodeID := firstTrimmed(resource.Docker.NodeID, resource.Docker.NodeName, resource.Name)
if nodeID == "" {
return ""
}
scope := firstTrimmed(dockerSwarmClusterKeyFromMeta(resource.Docker.Swarm), resource.Docker.HostSourceID)
if scope == "" {
return "docker-swarm-node:" + nodeID
}
return "docker-swarm-node:" + scope + ":" + nodeID
}
func canonicalDockerNodeID(resource Resource) string {
if resource.Docker == nil {
return ""
}
return strings.TrimSpace(resource.Docker.NodeID)
}
func canonicalDockerNodeName(resource Resource) string {
if resource.Docker == nil {
return ""
}
return strings.TrimSpace(resource.Docker.NodeName)
}
func canonicalKubernetesClusterID(resource Resource) string {
if resource.Kubernetes == nil {
return ""

View file

@ -175,6 +175,57 @@ func TestRefreshCanonicalIdentityUsesAvailabilityTargetIdentity(t *testing.T) {
}
}
func TestRefreshCanonicalIdentityUsesDockerSwarmNodeIdentity(t *testing.T) {
resource := Resource{
ID: "docker-swarm-node-resource-1",
Type: ResourceTypeDockerSwarmNode,
Name: "manager-1",
Identity: ResourceIdentity{
Hostnames: []string{"manager-1"},
},
Docker: &DockerData{
HostSourceID: "docker-host-1",
Hostname: "docker-host",
NodeID: "node-1",
NodeName: "manager-1",
NodeRole: "manager",
Swarm: &DockerSwarmInfo{
ClusterID: "cluster-1",
ClusterName: "prod-swarm",
},
},
}
RefreshCanonicalIdentity(&resource)
if resource.Canonical == nil {
t.Fatalf("expected canonical identity")
}
if got := resource.Canonical.DisplayName; got != "manager-1" {
t.Fatalf("displayName = %q, want manager-1", got)
}
if got := resource.Canonical.PrimaryID; got != "docker-swarm-node:cluster-1:node-1" {
t.Fatalf("primaryId = %q, want docker-swarm-node:cluster-1:node-1", got)
}
wantAliases := []string{
"docker-swarm-node:cluster-1:node-1",
"node-1",
"manager-1",
"docker-host-1",
"docker-host",
"docker-swarm-node-resource-1",
}
if len(resource.Canonical.Aliases) != len(wantAliases) {
t.Fatalf("aliases len = %d, want %d (%v)", len(resource.Canonical.Aliases), len(wantAliases), resource.Canonical.Aliases)
}
for i, want := range wantAliases {
if got := resource.Canonical.Aliases[i]; got != want {
t.Fatalf("alias[%d] = %q, want %q", i, got, want)
}
}
}
func TestRefreshCanonicalIdentityPrefersProxmoxNodePrimaryIDForAgentResources(t *testing.T) {
resource := Resource{
ID: "agent-1",

View file

@ -206,6 +206,7 @@ func cloneDockerData(in *DockerData) *DockerData {
out.BuildCacheUsage = cloneDockerStorageUsageMeta(in.BuildCacheUsage)
out.Ports = cloneDockerPortMetaSlice(in.Ports)
out.Labels = cloneStringMap(in.Labels)
out.EngineLabels = cloneStringMap(in.EngineLabels)
out.Networks = cloneDockerNetworkMetaSlice(in.Networks)
out.Mounts = cloneDockerMountMetaSlice(in.Mounts)
out.UpdateStatus = cloneDockerUpdateStatusMeta(in.UpdateStatus)
@ -225,6 +226,7 @@ func cloneDockerData(in *DockerData) *DockerData {
out.NetworksRaw = append([]models.DockerNetwork(nil), in.NetworksRaw...)
out.Services = append([]models.DockerService(nil), in.Services...)
out.Tasks = append([]models.DockerTask(nil), in.Tasks...)
out.Nodes = append([]models.DockerNode(nil), in.Nodes...)
return &out
}

View file

@ -154,6 +154,7 @@ func classifyResourceSensitivity(resource Resource) ResourceSensitivity {
ResourceTypeK8sServiceAccount,
ResourceTypeK8sEvent,
ResourceTypeDockerService,
ResourceTypeDockerSwarmNode,
ResourceTypeStorage,
ResourceTypeNetwork,
ResourceTypePBS,
@ -279,6 +280,8 @@ func resourceSummaryType(resource Resource) string {
return "application container"
case ResourceTypeDockerService:
return "docker service"
case ResourceTypeDockerSwarmNode:
return "docker swarm node"
case ResourceTypeK8sCluster:
return "kubernetes cluster"
case ResourceTypeK8sNode:

View file

@ -230,6 +230,45 @@ func (rr *ResourceRegistry) IngestSnapshot(snapshot models.StateSnapshot) {
for _, candidate := range serviceByID {
rr.ingestDockerService(candidate.service, candidate.host)
}
type dockerNodeCandidate struct {
host models.DockerHost
node models.DockerNode
}
nodeByID := make(map[string]dockerNodeCandidate)
for _, dh := range snapshot.DockerHosts {
if dh.Swarm == nil {
continue
}
for _, node := range dh.Nodes {
sourceID := dockerSwarmNodeSourceID(dh, node)
if sourceID == "" {
continue
}
existing, ok := nodeByID[sourceID]
if !ok {
nodeByID[sourceID] = dockerNodeCandidate{host: dh, node: node}
continue
}
replace := false
if existing.node.EngineVersion == "" && node.EngineVersion != "" {
replace = true
}
if existing.node.ManagerReachability == "" && node.ManagerReachability != "" {
replace = true
}
if !replace && dh.LastSeen.After(existing.host.LastSeen) {
replace = true
}
if replace {
nodeByID[sourceID] = dockerNodeCandidate{host: dh, node: node}
}
}
}
for _, candidate := range nodeByID {
rr.ingestDockerSwarmNode(candidate.node, candidate.host)
}
for _, dh := range snapshot.DockerHosts {
for _, task := range dh.Tasks {
rr.ingestDockerTask(task, dh)
@ -562,6 +601,22 @@ func (rr *ResourceRegistry) seedSourceIDForResourceLocked(resource *Resource, so
return ""
}
return hostSourceID + "/task/" + taskID
case ResourceTypeDockerSwarmNode:
nodeID := strings.TrimSpace(resource.Docker.NodeID)
if nodeID == "" {
nodeID = strings.TrimSpace(resource.Name)
}
if nodeID == "" {
return ""
}
if clusterKey := dockerSwarmClusterKeyFromMeta(resource.Docker.Swarm); clusterKey != "" {
return fmt.Sprintf("%s:node:%s", clusterKey, nodeID)
}
hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID)
if hostSourceID == "" {
return ""
}
return hostSourceID + "/swarm-node/" + nodeID
default:
return strings.TrimSpace(resource.Docker.HostSourceID)
}
@ -1304,6 +1359,18 @@ func (rr *ResourceRegistry) ingestDockerTask(task models.DockerTask, host models
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestDockerSwarmNode(node models.DockerNode, host models.DockerHost) {
resource, identity := resourceFromDockerSwarmNode(node, host)
if parentID := rr.sourceResourceID(SourceDocker, host.ID); parentID != "" {
resource.ParentID = &parentID
}
sourceID := dockerSwarmNodeSourceID(host, node)
if sourceID == "" {
return
}
rr.ingest(SourceDocker, sourceID, resource, identity)
}
func (rr *ResourceRegistry) ingestKubernetesCluster(cluster models.KubernetesCluster, linkedHosts []*models.Host, capabilities *K8sMetricCapabilities) string {
resource, identity := resourceFromKubernetesCluster(cluster, linkedHosts, capabilities)
sourceID := kubernetesClusterSourceID(cluster)
@ -3043,6 +3110,24 @@ func dockerTaskSourceID(host models.DockerHost, task models.DockerTask) string {
return hostID + "/task/" + taskID
}
func dockerSwarmNodeSourceID(host models.DockerHost, node models.DockerNode) string {
nodeID := strings.TrimSpace(node.ID)
if nodeID == "" {
nodeID = strings.TrimSpace(node.Hostname)
}
if nodeID == "" {
return ""
}
if cluster := dockerSwarmClusterKey(host); cluster != "" {
return fmt.Sprintf("%s:node:%s", cluster, nodeID)
}
hostID := strings.TrimSpace(host.ID)
if hostID == "" {
return ""
}
return hostID + "/swarm-node/" + nodeID
}
func mergeIdentity(existing ResourceIdentity, incoming ResourceIdentity) ResourceIdentity {
if existing.MachineID == "" {
existing.MachineID = incoming.MachineID

View file

@ -3881,6 +3881,14 @@ func TestRegistryIngestSnapshotPublishesDockerNativeInventory(t *testing.T) {
Tasks: []models.DockerTask{{
ID: "task-1", ServiceID: "svc-1", ServiceName: "api", Slot: 1, DesiredState: "running", CurrentState: "running",
}},
Swarm: &models.DockerSwarmInfo{
NodeID: "node-1",
NodeRole: "manager",
LocalState: "active",
},
Nodes: []models.DockerNode{{
ID: "node-1", Hostname: "manager-1", Role: "manager", State: "ready",
}},
}},
})
@ -3893,6 +3901,7 @@ func TestRegistryIngestSnapshotPublishesDockerNativeInventory(t *testing.T) {
ResourceTypeDockerVolume,
ResourceTypeDockerNetwork,
ResourceTypeDockerTask,
ResourceTypeDockerSwarmNode,
} {
if counts[resourceType] != 1 {
t.Fatalf("expected one %s resource, got counts %#v", resourceType, counts)

View file

@ -123,6 +123,28 @@ func statusFromDockerTask(task models.DockerTask) ResourceStatus {
}
}
func statusFromDockerSwarmNode(node models.DockerNode) ResourceStatus {
state := strings.ToLower(strings.TrimSpace(node.State))
reachability := strings.ToLower(strings.TrimSpace(node.ManagerReachability))
availability := strings.ToLower(strings.TrimSpace(node.Availability))
switch {
case state == "ready" || state == "active":
if reachability == "unreachable" || availability == "pause" || availability == "drain" {
return StatusWarning
}
return StatusOnline
case state == "down" || state == "disconnected":
return StatusOffline
case state == "unknown":
return StatusUnknown
case reachability == "unreachable":
return StatusWarning
default:
return StatusUnknown
}
}
func statusFromPBSInstance(instance models.PBSInstance) ResourceStatus {
primary := strings.ToLower(strings.TrimSpace(instance.Status))
health := strings.ToLower(strings.TrimSpace(instance.ConnectionHealth))

View file

@ -129,6 +129,7 @@ const (
ResourceTypeDockerVolume ResourceType = "docker-volume"
ResourceTypeDockerNetwork ResourceType = "docker-network"
ResourceTypeDockerTask ResourceType = "docker-task"
ResourceTypeDockerSwarmNode ResourceType = "docker-swarm-node"
ResourceTypeK8sCluster ResourceType = "k8s-cluster"
ResourceTypeK8sNode ResourceType = "k8s-node"
ResourceTypePod ResourceType = "pod"
@ -836,6 +837,7 @@ type DockerData struct {
ImageCount int `json:"imageCount,omitempty"`
VolumeCount int `json:"volumeCount,omitempty"`
NetworkCount int `json:"networkCount,omitempty"`
NodeCount int `json:"nodeCount,omitempty"`
UpdatesAvailableCount int `json:"updatesAvailableCount,omitempty"`
UpdatesLastCheckedAt *time.Time `json:"updatesLastCheckedAt,omitempty"`
ImagesUsage *DockerStorageUsageMeta `json:"imagesUsage,omitempty"`
@ -912,6 +914,18 @@ type DockerData struct {
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
// Swarm-node-specific fields (populated when Resource.Type == ResourceTypeDockerSwarmNode)
NodeRole string `json:"nodeRole,omitempty"`
Availability string `json:"availability,omitempty"`
Address string `json:"address,omitempty"`
ManagerReachability string `json:"managerReachability,omitempty"`
ManagerAddress string `json:"managerAddress,omitempty"`
Leader bool `json:"leader,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
NanoCPUs int64 `json:"nanoCpus,omitempty"`
MemoryBytes int64 `json:"memoryBytes,omitempty"`
EngineLabels map[string]string `json:"engineLabels,omitempty"`
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
NetworkInterfaces []NetworkInterface `json:"networkInterfaces,omitempty"`
Disks []DiskInfo `json:"disks,omitempty"`
@ -923,6 +937,7 @@ type DockerData struct {
NetworksRaw []models.DockerNetwork `json:"-"`
Services []models.DockerService `json:"-"`
Tasks []models.DockerTask `json:"-"`
Nodes []models.DockerNode `json:"-"`
}
// PBSData contains Proxmox Backup Server data.

View file

@ -1562,6 +1562,13 @@ func (v DockerHostView) Tasks() []models.DockerTask {
return append([]models.DockerTask(nil), v.r.Docker.Tasks...)
}
func (v DockerHostView) Nodes() []models.DockerNode {
if v.r == nil || v.r.Docker == nil {
return nil
}
return append([]models.DockerNode(nil), v.r.Docker.Nodes...)
}
func (v DockerHostView) NetworkInterfaces() []NetworkInterface {
if v.r == nil || v.r.Docker == nil {
return nil

View file

@ -20,6 +20,7 @@ type Report struct {
Networks []Network `json:"networks,omitempty"`
Services []Service `json:"services,omitempty"`
Tasks []Task `json:"tasks,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
StorageUsage *StorageUsage `json:"storageUsage,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
@ -289,3 +290,26 @@ type Task struct {
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
// Node summarises a Docker Swarm node returned by the manager-side nodes API.
type Node struct {
ID string `json:"id"`
Hostname string `json:"hostname,omitempty"`
Role string `json:"role,omitempty"`
Availability string `json:"availability,omitempty"`
State string `json:"state,omitempty"`
Message string `json:"message,omitempty"`
Address string `json:"address,omitempty"`
ManagerReachability string `json:"managerReachability,omitempty"`
ManagerAddress string `json:"managerAddress,omitempty"`
Leader bool `json:"leader,omitempty"`
EngineVersion string `json:"engineVersion,omitempty"`
OS string `json:"os,omitempty"`
Architecture string `json:"architecture,omitempty"`
NanoCPUs int64 `json:"nanoCpus,omitempty"`
MemoryBytes int64 `json:"memoryBytes,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
EngineLabels map[string]string `json:"engineLabels,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}

View file

@ -388,6 +388,38 @@ func TestTask_Fields(t *testing.T) {
}
}
func TestNode_Fields(t *testing.T) {
now := time.Now()
node := Node{
ID: "node123",
Hostname: "manager-1",
Role: "manager",
Availability: "active",
State: "ready",
ManagerReachability: "reachable",
Leader: true,
EngineVersion: "27.5.1",
NanoCPUs: 4_000_000_000,
MemoryBytes: 16 * 1024 * 1024 * 1024,
Labels: map[string]string{"zone": "rack-a"},
EngineLabels: map[string]string{"engine": "primary"},
CreatedAt: now,
}
if node.ID != "node123" {
t.Errorf("ID = %q, want node123", node.ID)
}
if node.Role != "manager" || node.State != "ready" {
t.Errorf("unexpected node state: %+v", node)
}
if !node.Leader || node.ManagerReachability != "reachable" {
t.Errorf("unexpected manager fields: %+v", node)
}
if node.Labels["zone"] != "rack-a" || node.EngineLabels["engine"] != "primary" {
t.Errorf("unexpected labels: %+v / %+v", node.Labels, node.EngineLabels)
}
}
func TestCommand_Fields(t *testing.T) {
cmd := Command{
ID: "cmd-123",

View file

@ -3589,7 +3589,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 241,
"line": 242,
"heading_line": 109,
}
],