diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
index 1e8f2ce19..369938646 100644
--- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
+++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
@@ -86,7 +86,8 @@
"docker-volume",
"docker-network",
"docker-service",
- "docker-task"
+ "docker-task",
+ "docker-swarm-node"
],
"support_floor": {
"setup": "supported",
diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md
index a1b1ddcf8..bd2a736f7 100644
--- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md
+++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md
@@ -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` |
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index c87bdd37f..5576120f3 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 0f9ef80b0..afb5539f6 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 2de34178a..710215704 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 96d786276..9957d9486 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/organization-settings.md b/docs/release-control/v6/internal/subsystems/organization-settings.md
index 680e8e221..9086c3cf9 100644
--- a/docs/release-control/v6/internal/subsystems/organization-settings.md
+++ b/docs/release-control/v6/internal/subsystems/organization-settings.md
@@ -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,
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 1af78a998..38651899a 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -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`
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 08a3796c1..b78c8162b 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -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
diff --git a/frontend-modern/src/features/docker/DockerInventoryTable.tsx b/frontend-modern/src/features/docker/DockerInventoryTable.tsx
index a05771514..ef779ced1 100644
--- a/frontend-modern/src/features/docker/DockerInventoryTable.tsx
+++ b/frontend-modern/src/features/docker/DockerInventoryTable.tsx
@@ -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
);
}
+ if (props.variant === 'nodes') {
+ return (
+
+ );
+ }
return (
);
};
diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx
index 94ddfb5d8..8bc55a3e6 100644
--- a/frontend-modern/src/features/docker/DockerPageSurface.tsx
+++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx
@@ -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."
/>
+
+
+
+
+
+
+ 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 —;
+ const totalSize = bucket?.totalSizeBytes ?? 0;
+ const reclaimable = bucket?.reclaimableBytes ?? 0;
+ const count = bucket?.totalCount ?? 0;
+ const active = bucket?.activeCount ?? 0;
+ return (
+
+ {formatBytes(totalSize)}
+
+ {count} total, {active} active, {formatBytes(reclaimable)} reclaimable
+
+
+ );
+};
+
+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 (
+ 0}
+ fallback={
+
+ }
+ >
+
+
+
+
0}
+ fallback={
+
+ }
+ >
+
+
+
+
+
+
+
+
+ {(host) => {
+ const name = () => asTrimmedString(host.name) || host.id;
+ const indicator = () => getSimpleStatusIndicator(host.status);
+ return (
+
+
+
+
+
+ {name()}
+
+
+
+
+ {bucketValue(host.docker?.imagesUsage)}
+
+
+ {bucketValue(host.docker?.containersUsage)}
+
+
+ {bucketValue(host.docker?.volumesUsage)}
+
+
+ {bucketValue(host.docker?.buildCacheUsage)}
+
+
+ );
+ }}
+
+
+
+
+
+
+
+ );
+};
+
+export default DockerStorageUsageTable;
diff --git a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx
index dacc718bf..01e39c981 100644
--- a/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx
+++ b/frontend-modern/src/features/docker/__tests__/DockerPageSurface.test.tsx
@@ -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(() => );
+ expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: expect.stringContaining('docker-swarm-node'),
+ }),
+ );
expect(mocks.useWorkloadsState).toHaveBeenCalledWith(
expect.objectContaining({
compactGroupHeaders: true,
diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts
index ba8d8cc27..106c64aa1 100644
--- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts
+++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts
@@ -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 => ({
});
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({
diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts
index 93ac57763..f7852e1f5 100644
--- a/frontend-modern/src/features/docker/dockerPageModel.ts
+++ b/frontend-modern/src/features/docker/dockerPageModel.ts
@@ -15,6 +15,7 @@ const DOCKER_IMAGE_TYPES = new Set(['docker-image']);
const DOCKER_VOLUME_TYPES = new Set(['docker-volume']);
const DOCKER_NETWORK_TYPES = new Set(['docker-network']);
const DOCKER_TASK_TYPES = new Set(['docker-task']);
+const DOCKER_SWARM_NODE_TYPES = new Set(['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
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 ?? []),
diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts
index 763cbf9af..2653162b7 100644
--- a/frontend-modern/src/hooks/useUnifiedResources.ts
+++ b/frontend-modern/src/hooks/useUnifiedResources.ts
@@ -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':
diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts
index 52e0b273c..373aa71d8 100644
--- a/frontend-modern/src/types/__tests__/resource.test.ts
+++ b/frontend-modern/src/types/__tests__/resource.test.ts
@@ -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',
diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts
index 47166fff7..41a1dc4de 100644
--- a/frontend-modern/src/types/resource.ts
+++ b/frontend-modern/src/types/resource.ts
@@ -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;
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 {
diff --git a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts
index 26c5b5a76..24bb0a337 100644
--- a/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts
+++ b/frontend-modern/src/utils/__tests__/canonicalResourceTypes.test.ts
@@ -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);
diff --git a/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts
index fff895caf..ebea2d084 100644
--- a/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts
+++ b/frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts
@@ -23,6 +23,7 @@ describe('reportableResourceTypes', () => {
'docker-volume',
'docker-network',
'docker-task',
+ 'docker-swarm-node',
'k8s-namespace',
'k8s-service',
'k8s-replicaset',
diff --git a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts
index eefb010ee..3f12160d4 100644
--- a/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts
+++ b/frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts
@@ -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');
diff --git a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts
index 1df8320c5..accbed3f5 100644
--- a/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts
+++ b/frontend-modern/src/utils/__tests__/resourceTypeCompat.test.ts
@@ -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');
diff --git a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts
index 8332b6d19..bc98c11e0 100644
--- a/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/resourceTypePresentation.test.ts
@@ -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');
diff --git a/frontend-modern/src/utils/canonicalResourceTypes.ts b/frontend-modern/src/utils/canonicalResourceTypes.ts
index 9521878ea..effb78133 100644
--- a/frontend-modern/src/utils/canonicalResourceTypes.ts
+++ b/frontend-modern/src/utils/canonicalResourceTypes.ts
@@ -16,6 +16,7 @@ export const CANONICAL_RESOURCE_TYPES = [
'docker-volume',
'docker-network',
'docker-task',
+ 'docker-swarm-node',
'k8s-deployment',
'k8s-replicaset',
'k8s-service',
diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts
index 1738fa89c..af6e1a98d 100644
--- a/frontend-modern/src/utils/platformSupportManifest.generated.ts
+++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts
@@ -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',
diff --git a/frontend-modern/src/utils/reportingResourceTypes.ts b/frontend-modern/src/utils/reportingResourceTypes.ts
index d5004bf68..bdcfc072c 100644
--- a/frontend-modern/src/utils/reportingResourceTypes.ts
+++ b/frontend-modern/src/utils/reportingResourceTypes.ts
@@ -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';
diff --git a/frontend-modern/src/utils/resourceTypeCompat.ts b/frontend-modern/src/utils/resourceTypeCompat.ts
index b1f28efba..bc79a24f8 100644
--- a/frontend-modern/src/utils/resourceTypeCompat.ts
+++ b/frontend-modern/src/utils/resourceTypeCompat.ts
@@ -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':
diff --git a/frontend-modern/src/utils/resourceTypePresentation.ts b/frontend-modern/src/utils/resourceTypePresentation.ts
index 5ebc80885..9a71cba9e 100644
--- a/frontend-modern/src/utils/resourceTypePresentation.ts
+++ b/frontend-modern/src/utils/resourceTypePresentation.ts
@@ -70,6 +70,10 @@ const RESOURCE_TYPE_PRESENTATION: Partial 0 {
report.Tasks = tasks
}
+ if len(nodes) > 0 {
+ report.Nodes = nodes
+ }
if storageUsage != nil {
report.StorageUsage = storageUsage
}
diff --git a/internal/dockeragent/docker_client.go b/internal/dockeragent/docker_client.go
index 7104a13be..fff85deca 100644
--- a/internal/dockeragent/docker_client.go
+++ b/internal/dockeragent/docker_client.go
@@ -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 {
diff --git a/internal/dockeragent/swarm.go b/internal/dockeragent/swarm.go
index 7cfbabf36..864c04746 100644
--- a/internal/dockeragent/swarm.go
+++ b/internal/dockeragent/swarm.go
@@ -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":
diff --git a/internal/dockeragent/swarm_coverage_test.go b/internal/dockeragent/swarm_coverage_test.go
index dcb1863dc..8d17671f2 100644
--- a/internal/dockeragent/swarm_coverage_test.go
+++ b/internal/dockeragent/swarm_coverage_test.go
@@ -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")
}
diff --git a/internal/dockeragent/test_helpers_test.go b/internal/dockeragent/test_helpers_test.go
index 75457b01f..0e5f8c647 100644
--- a/internal/dockeragent/test_helpers_test.go
+++ b/internal/dockeragent/test_helpers_test.go
@@ -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)
diff --git a/internal/models/converters.go b/internal/models/converters.go
index 13f540b6c..9dcb1de96 100644
--- a/internal/models/converters.go
+++ b/internal/models/converters.go
@@ -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)
diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go
index 45664b510..8fcc2bf56 100644
--- a/internal/models/converters_test.go
+++ b/internal/models/converters_test.go
@@ -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) {
diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go
index 5beff08bc..3e4a30859 100644
--- a/internal/models/deepcopy.go
+++ b/internal/models/deepcopy.go
@@ -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)
diff --git a/internal/models/models.go b/internal/models/models.go
index acc29166f..e803180dc 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -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"`
diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go
index 12c9ae705..6883274b3 100644
--- a/internal/models/models_frontend.go
+++ b/internal/models/models_frontend.go
@@ -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"`
diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go
index 5e27769aa..fbad01d8b 100644
--- a/internal/monitoring/canonical_guardrails_test.go
+++ b/internal/monitoring/canonical_guardrails_test.go
@@ -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) {
diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go
index 52e03853e..c9c9a96f6 100644
--- a/internal/monitoring/monitor_agents.go
+++ b/internal/monitoring/monitor_agents.go
@@ -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,
diff --git a/internal/monitoring/monitor_helpers.go b/internal/monitoring/monitor_helpers.go
index 214db544b..007d08671 100644
--- a/internal/monitoring/monitor_helpers.go
+++ b/internal/monitoring/monitor_helpers.go
@@ -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 == "" {
diff --git a/internal/monitoring/monitor_helpers_test.go b/internal/monitoring/monitor_helpers_test.go
index f01f11692..2fdedfd68 100644
--- a/internal/monitoring/monitor_helpers_test.go
+++ b/internal/monitoring/monitor_helpers_test.go
@@ -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) {
diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go
index 47a889469..f12b983c3 100644
--- a/internal/monitoring/monitor_host_agents_test.go
+++ b/internal/monitoring/monitor_host_agents_test.go
@@ -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()
diff --git a/internal/platformsupport/manifest_generated.go b/internal/platformsupport/manifest_generated.go
index 962e9d061..5a72df039 100644
--- a/internal/platformsupport/manifest_generated.go
+++ b/internal/platformsupport/manifest_generated.go
@@ -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
diff --git a/internal/unifiedresources/adapter_coverage_test.go b/internal/unifiedresources/adapter_coverage_test.go
index 2ac4dc0e2..1f618eb53 100644
--- a/internal/unifiedresources/adapter_coverage_test.go
+++ b/internal/unifiedresources/adapter_coverage_test.go
@@ -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) {
diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go
index 6b96d8028..689253042 100644
--- a/internal/unifiedresources/adapters.go
+++ b/internal/unifiedresources/adapters.go
@@ -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)
diff --git a/internal/unifiedresources/canonical_identity.go b/internal/unifiedresources/canonical_identity.go
index 3a1c14cdb..1b8d62671 100644
--- a/internal/unifiedresources/canonical_identity.go
+++ b/internal/unifiedresources/canonical_identity.go
@@ -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 ""
diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go
index fbafde3f0..0692bede3 100644
--- a/internal/unifiedresources/canonical_identity_test.go
+++ b/internal/unifiedresources/canonical_identity_test.go
@@ -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",
diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go
index 19a3bf1a9..a89b1da9c 100644
--- a/internal/unifiedresources/clone.go
+++ b/internal/unifiedresources/clone.go
@@ -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
}
diff --git a/internal/unifiedresources/policy_metadata.go b/internal/unifiedresources/policy_metadata.go
index 7ac8ed8d0..054137c4d 100644
--- a/internal/unifiedresources/policy_metadata.go
+++ b/internal/unifiedresources/policy_metadata.go
@@ -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:
diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go
index a4126f663..5c372b078 100644
--- a/internal/unifiedresources/registry.go
+++ b/internal/unifiedresources/registry.go
@@ -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
diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go
index a80525e50..ab0ba959f 100644
--- a/internal/unifiedresources/registry_test.go
+++ b/internal/unifiedresources/registry_test.go
@@ -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)
diff --git a/internal/unifiedresources/status.go b/internal/unifiedresources/status.go
index 2ada8f5b9..809395acf 100644
--- a/internal/unifiedresources/status.go
+++ b/internal/unifiedresources/status.go
@@ -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))
diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go
index 47f36340e..19b1cf2fc 100644
--- a/internal/unifiedresources/types.go
+++ b/internal/unifiedresources/types.go
@@ -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.
diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go
index e70aa9fd3..92693cd31 100644
--- a/internal/unifiedresources/views.go
+++ b/internal/unifiedresources/views.go
@@ -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
diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go
index 3358fac87..46169e3df 100644
--- a/pkg/agents/docker/report.go
+++ b/pkg/agents/docker/report.go
@@ -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"`
+}
diff --git a/pkg/agents/docker/report_test.go b/pkg/agents/docker/report_test.go
index 38c7caeab..7b7484889 100644
--- a/pkg/agents/docker/report_test.go
+++ b/pkg/agents/docker/report_test.go
@@ -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",
diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py
index c442dd1f9..38f736f4e 100644
--- a/scripts/release_control/subsystem_lookup_test.py
+++ b/scripts/release_control/subsystem_lookup_test.py
@@ -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,
}
],