diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index fbb008d5c..e360e392d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -188,6 +188,10 @@ 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. Platform tabs are +feature-owned consumers of canonical resource payloads: Docker page model +helpers may prefer backend-authored `DockerData` stack and Podman metadata for +search/display while shared primitives continue to own only the tab shell, +filter controls, and reusable presentation affordances. Platform tabs are workflow-level navigation, not one visible tab per API resource kind, and they must be evidence-gated by their owning row or signal model. `Overview` is the stable landing surface; supporting workflow tabs appear only when the current diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 79887610a..f097bd16d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -762,6 +762,12 @@ recovery scope, or a storage/recovery-owned secret source. recovery must treat that fallback as descriptive host/VM uptime only; they must not reinterpret it as backup freshness, recovery point recency, or protection cadence. + Docker / Podman `DockerData` container lifecycle, Podman metadata, and + cumulative block I/O totals remain unified-resource runtime context. + Storage and recovery may use those fields only as workload description + when linking to an owning runtime/platform page; they must not reinterpret + container block I/O totals as backup throughput, recovery-point evidence, + protection cadence, or storage-health ownership. 30. Keep VMware placement, cluster service state, guest-detail, VM snapshot-tree, VM virtual-hardware configuration, VMware Tools, VM hardware Ethernet, VM hardware disk, and network enrichment descriptive on that same shared unified-resource contract. When `internal/vmware/provider.go`, `internal/unifiedresources/types.go`, and `frontend-modern/src/hooks/useUnifiedResources.ts` project datacenter, cluster, `vmware.clusterHaEnabled`, `vmware.clusterDrsEnabled`, folder, runtime-host, datastore-attachment, guest-hostname, guest-IP, `vmware.currentSnapshotId`, `vmware.snapshotTree`, snapshot creation/state/quiesce/current markers, child snapshot metadata, `vmware.hardware`, virtual hardware version, hardware upgrade policy/version/status/error, boot type/order/retry/setup-mode flags, CPU cores-per-socket and hot-add/remove flags, memory hot-add settings, `vmware.tools`, Tools run state, version status, version number/string, install type, upgrade policy, auto-update support, install-attempt count, guest reboot requests, `vmware.networkAdapters`, adapter MAC address/type, backing network id/name, backing type, connection state, start-connected / guest-control flags, `vmware.virtualDisks`, virtual disk label/type, IDE/SCSI/SATA/NVMe placement, VMDK path, backing type, datastore name, capacity, `vmware.networkType`, `vmware.networkHostNames`, or `vmware.networkVmNames` onto canonical VMware `agent` / `vm` / `storage` / `network` resources, storage and recovery may use that detail for labeling, navigation, and VM investigation context only; they must not promote those topology, cluster-service, guest, snapshot-tree, virtual-hardware, VMware Tools, vNIC, virtual disk, or network fields into recovery ownership, restore targeting, protection grouping, compliance scoring, or a VMware-local recovery taxonomy without a separately governed slice. 31. Keep VMware datastore classification neutral on the shared storage adapter contract. When `frontend-modern/src/features/storageBackups/resourceStorageMapping.ts`, `frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts`, and `frontend-modern/src/features/storageBackups/storageAdapters.ts` evolve canonical storage-record mapping, VMware-backed datastores must continue to land on the shared storage route as inventory-only datastores with neutral protection fallback, not as backup repositories, backup targets, or recovery-protected resources. That same shared storage adapter boundary also owns canonical platform diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c07cd60ff..763e11e9d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -246,6 +246,11 @@ those rows through a variant-switched generic inventory table. Swarm service rows must preserve the service update status emitted by the Docker adapter, and engine storage rows must stay host-scoped with table proof hooks so browser proof can distinguish a populated disk-usage tab from an empty fixture. +Runtime container detail payloads must preserve the agent-reported lifecycle +timestamps, Podman pod/compose/auto-update/user-namespace metadata, and +cumulative block I/O totals on `DockerData`; frontend detail summaries and +Docker page search consume those backend-authored fields before falling back to +legacy labels. Docker network rows must consume canonical runtime attachment relationships, not page-local topology inference, when the unified-resource snapshot provides them. Runtime container-to-network membership is represented as active diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 74ab15554..b22b9a22e 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -3,7 +3,7 @@ import type { Component } from 'solid-js'; import type { Resource } from '@/types/resource'; import { Card } from '@/components/shared/Card'; import { TagBadges } from '@/components/shared/TagBadges'; -import { formatRelativeTime, formatUptime } from '@/utils/format'; +import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatInteger } from './resourceDetailMappers'; import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState'; @@ -21,10 +21,22 @@ const dockerContainerMeta = (resource: Resource): NonNullable (value || '').trim(); + const composeLabelValue = ( labels: Record | undefined, suffix: 'project' | 'service', @@ -35,19 +47,35 @@ const composeLabelValue = ( '' ).trim(); -const dockerCreatedAtMillis = (docker: NonNullable): number | null => { - const raw = (docker.createdAt || '').trim(); +const dockerTimestampMillis = (value: string | undefined): number | null => { + const raw = trimmedDockerValue(value); if (!raw) return null; const parsed = Date.parse(raw); return Number.isFinite(parsed) ? parsed : null; }; +const dockerByteTotal = (value: number | undefined): number | null => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; + const DockerContainerSummarySection: Component<{ docker: NonNullable }> = ( props, ) => { const labels = () => props.docker.labels ?? {}; const labelEntries = () => Object.entries(labels()); - const createdAt = () => dockerCreatedAtMillis(props.docker); + const createdAt = () => dockerTimestampMillis(props.docker.createdAt); + const startedAt = () => dockerTimestampMillis(props.docker.startedAt); + const finishedAt = () => dockerTimestampMillis(props.docker.finishedAt); + const podman = () => props.docker.podman; + const podmanPodName = () => trimmedDockerValue(podman()?.podName); + const podmanPodId = () => trimmedDockerValue(podman()?.podId); + const composeProject = () => + trimmedDockerValue(podman()?.composeProject) || composeLabelValue(labels(), 'project'); + const composeService = () => + trimmedDockerValue(podman()?.composeService) || composeLabelValue(labels(), 'service'); + const autoUpdatePolicy = () => trimmedDockerValue(podman()?.autoUpdatePolicy); + const userNamespace = () => trimmedDockerValue(podman()?.userNamespace); + const blockReadBytes = () => dockerByteTotal(props.docker.blockIo?.readBytes); + const blockWriteBytes = () => dockerByteTotal(props.docker.blockIo?.writeBytes); const restartCount = () => props.docker.restartCount; return ( @@ -76,9 +104,7 @@ const DockerContainerSummarySection: Component<{ docker: NonNullableRestarts 5 - ? 'text-red-600 dark:text-red-400' - : 'text-base-content' + (restartCount() ?? 0) > 5 ? 'text-red-600 dark:text-red-400' : 'text-base-content' }`} > {formatInteger(restartCount())} @@ -98,22 +124,102 @@ const DockerContainerSummarySection: Component<{ docker: NonNullable )} - + + {(started) => ( + + Started + + {formatRelativeTime(started())} + + + )} + + + {(finished) => ( + + Finished + + {formatRelativeTime(finished())} + + + )} + + - Compose project - - {composeLabelValue(labels(), 'project')} + Podman pod + + {podmanPodName()} - + - Compose service - - {composeLabelValue(labels(), 'service')} + Podman pod ID + + {podmanPodId()} + + + Podman infra + + {podman()?.infra ? 'Yes' : 'No'} + + + + + + Compose project + {composeProject()} + + + + + Compose service + {composeService()} + + + + + Auto-update + {autoUpdatePolicy()} + + + + + User namespace + + {userNamespace()} + + + + + {(readBytes) => ( + + Block I/O read + + {formatBytes(readBytes())} + + + )} + + + {(writeBytes) => ( + + Block I/O write + + {formatBytes(writeBytes())} + + + )} + 0}> Labels diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx index 22de9bf11..872c19f3e 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx @@ -99,9 +99,21 @@ describe('ResourceDetailDrawer for Docker containers', () => { image: 'ghcr.io/example/edge-web:2026.05', restartCount: 7, createdAt: '2026-04-13T01:14:01Z', + startedAt: '2026-04-13T01:15:01Z', + finishedAt: '2026-04-13T02:15:01Z', + blockIo: { readBytes: 1_048_576, writeBytes: 2_097_152 }, + podman: { + podName: 'edge-pod', + podId: 'pod-123', + infra: true, + composeProject: 'orion', + composeService: 'web', + autoUpdatePolicy: 'registry', + userNamespace: 'keep-id', + }, labels: { - 'com.docker.compose.project': 'orion', - 'com.docker.compose.service': 'web', + 'com.docker.compose.project': 'legacy-project', + 'com.docker.compose.service': 'legacy-service', 'traefik.enable': 'true', }, }, @@ -115,10 +127,27 @@ describe('ResourceDetailDrawer for Docker containers', () => { expect(within(section).getByText('Restarts')).toBeInTheDocument(); expect(within(section).getByText('7')).toHaveClass('text-red-600'); expect(within(section).getByText('Created')).toBeInTheDocument(); - expect(within(section).getByText(/ago$/)).toBeInTheDocument(); + expect(within(section).getByText('Started')).toBeInTheDocument(); + expect(within(section).getByText('Finished')).toBeInTheDocument(); + expect(within(section).getAllByText(/ago$/).length).toBeGreaterThanOrEqual(3); + expect(within(section).getByText('Podman pod')).toBeInTheDocument(); + expect(within(section).getByText('edge-pod')).toBeInTheDocument(); + expect(within(section).getByText('Podman pod ID')).toBeInTheDocument(); + expect(within(section).getByText('pod-123')).toBeInTheDocument(); + expect(within(section).getByText('Podman infra')).toBeInTheDocument(); + expect(within(section).getByText('Yes')).toBeInTheDocument(); expect(within(section).getByText('Compose project')).toBeInTheDocument(); expect(within(section).getByText('orion')).toBeInTheDocument(); + expect(within(section).queryByText('legacy-project')).not.toBeInTheDocument(); expect(within(section).getByText('Compose service')).toBeInTheDocument(); + expect(within(section).getByText('Auto-update')).toBeInTheDocument(); + expect(within(section).getByText('registry')).toBeInTheDocument(); + expect(within(section).getByText('User namespace')).toBeInTheDocument(); + expect(within(section).getByText('keep-id')).toBeInTheDocument(); + expect(within(section).getByText('Block I/O read')).toBeInTheDocument(); + expect(within(section).getByText('1.00 MB')).toBeInTheDocument(); + expect(within(section).getByText('Block I/O write')).toBeInTheDocument(); + expect(within(section).getByText('2.00 MB')).toBeInTheDocument(); expect(within(section).getByText('Labels')).toBeInTheDocument(); expect(within(section).getByText(/traefik\.enable/)).toBeInTheDocument(); }); diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 4ce4aed05..587e8e779 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -10,6 +10,7 @@ import { compareDockerServices, compareDockerSwarmNodes, compareDockerTasks, + dockerServiceStack, filterDockerIncidents, filterDockerResources, getDockerHostSystemBadge, @@ -811,13 +812,32 @@ describe('dockerPageModel', () => { status: 'online', docker: { serviceName: 'shop-api', - labels: { 'com.docker.stack.namespace': 'shopstack' }, + stack: 'shopstack', + labels: { 'com.docker.stack.namespace': 'legacy-stack' }, + }, + }), + makeResource({ + id: 'podman-web', + type: 'app-container', + status: 'online', + docker: { + containerState: 'running', + podman: { + podName: 'edge-pod', + podId: 'pod-123', + infra: false, + composeProject: 'orion', + composeService: 'web', + autoUpdatePolicy: 'registry', + userNamespace: 'keep-id', + }, }, }), ]; expect(filterDockerResources(labelled, 'orion', 'all').map((r) => r.id)).toEqual([ 'compose-db', + 'podman-web', ]); expect(filterDockerResources(labelled, 'database', 'all').map((r) => r.id)).toEqual([ 'compose-db', @@ -825,6 +845,21 @@ describe('dockerPageModel', () => { expect(filterDockerResources(labelled, 'shopstack', 'all').map((r) => r.id)).toEqual([ 'svc-stacked', ]); + expect(dockerServiceStack(labelled.find((resource) => resource.id === 'svc-stacked')!)).toBe( + 'shopstack', + ); + expect(filterDockerResources(labelled, 'legacy-stack', 'all').map((r) => r.id)).toEqual([ + 'svc-stacked', + ]); + expect(filterDockerResources(labelled, 'pod:edge-pod', 'all').map((r) => r.id)).toEqual([ + 'podman-web', + ]); + expect(filterDockerResources(labelled, 'compose:orion', 'all').map((r) => r.id)).toEqual([ + 'podman-web', + ]); + expect(filterDockerResources(labelled, 'keep-id', 'all').map((r) => r.id)).toEqual([ + 'podman-web', + ]); }); it('still combines status and search', () => { @@ -840,9 +875,11 @@ describe('dockerPageModel', () => { .map((r) => r.id) .sort(), ).toEqual(['edge-proxy', 'redis-vol', 'svc-payments', 'svc-search', 'web'].sort()); - expect(filterDockerResources(rows, '', 'degraded').map((r) => r.id).sort()).toEqual( - ['svc-payments', 'svc-search'].sort(), - ); + expect( + filterDockerResources(rows, '', 'degraded') + .map((r) => r.id) + .sort(), + ).toEqual(['svc-payments', 'svc-search'].sort()); }); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 73ed4bc38..b6c8559fb 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -377,20 +377,21 @@ export function buildDockerNetworkAttachmentRows( .sort((left, right) => compareDockerContainers(left.resource, right.resource)); } -// The API serializes a `stack` field for Swarm services, but the frontend -// ResourceDockerMeta type does not declare it yet; until it does, fall back -// to the canonical Swarm stack label every stack deploy stamps on the -// service. +// Prefer the backend-authored service stack while keeping the Docker label as +// compatibility for older payloads. export const dockerServiceStack = (resource: Resource): string => { - const docker = resource.docker as - | (NonNullable & { stack?: string }) - | undefined; + const docker = resource.docker; return ( asTrimmedString(docker?.stack) || asTrimmedString(docker?.labels?.['com.docker.stack.namespace']) ); }; +const dockerPrefixedToken = (prefix: string, value: string | undefined): string | undefined => { + const trimmed = asTrimmedString(value); + return trimmed ? `${prefix}:${trimmed}` : undefined; +}; + // Builds the lowercase search haystack a Docker page table consults when // filtering rows. The shared platformPage helper carries only generic Resource // fields; docker.* lookups live here so the cross-platform helper does not @@ -450,6 +451,16 @@ export function dockerResourceSearchHaystack(resource: Resource): string { docker?.swarm?.clusterName, docker?.swarm?.nodeRole, docker?.swarm?.localState, + docker?.podman?.podName, + docker?.podman?.podId, + docker?.podman?.composeProject, + docker?.podman?.composeService, + docker?.podman?.autoUpdatePolicy, + docker?.podman?.userNamespace, + dockerPrefixedToken('pod', docker?.podman?.podName), + dockerPrefixedToken('pod', docker?.podman?.podId), + dockerPrefixedToken('compose', docker?.podman?.composeProject), + dockerPrefixedToken('compose', docker?.podman?.composeService), ...(docker?.ports?.flatMap((port) => [ dockerPortToken(port), port.ip, @@ -470,7 +481,9 @@ export function dockerResourceSearchHaystack(resource: Resource): string { // Labels carry the operator-meaningful container organizers (compose // project/service, Swarm stack, traefik rules); v5 searched them and the // unified payload still ships them. - ...(docker?.labels ? Object.entries(docker.labels).flatMap(([key, value]) => [key, value]) : []), + ...(docker?.labels + ? Object.entries(docker.labels).flatMap(([key, value]) => [key, value]) + : []), dockerServiceStack(resource), ...(resource.tags ?? []), ] diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index e17e2f3cc..91033e7fb 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -170,6 +170,19 @@ describe('Resource Helper Functions', () => { updatesAvailableCount: 2, updatesLastCheckedAt: '2026-05-17T18:00:00Z', command: { restartContainer: true }, + startedAt: '2026-06-11T13:15:30Z', + finishedAt: '2026-06-11T14:00:30Z', + blockIo: { readBytes: 9_876_543, writeBytes: 1_234_567 }, + podman: { + podName: 'edge-pod', + podId: 'pod-123', + infra: true, + composeProject: 'orion', + composeService: 'web', + autoUpdatePolicy: 'registry', + userNamespace: 'keep-id', + }, + stack: 'shop', swarm: { clusterId: 'swarm-1', clusterName: 'homelab', @@ -182,6 +195,9 @@ describe('Resource Helper Functions', () => { expect(docker.runtimeVersion).toBe('27.5.1'); expect(docker.containerCount).toBe(12); + expect(docker.podman?.composeProject).toBe('orion'); + expect(docker.blockIo?.readBytes).toBe(9_876_543); + expect(docker.stack).toBe('shop'); expect(docker.swarm?.localState).toBe('active'); expect(docker.swarm?.controlAvailable).toBe(true); }); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index f1481cf13..73a63ffcd 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -681,6 +681,20 @@ export interface ResourceDockerMeta { health?: string; restartCount?: number; exitCode?: number; + finishedAt?: string; + blockIo?: { + readBytes?: number; + writeBytes?: number; + }; + podman?: { + podName?: string; + podId?: string; + infra?: boolean; + composeProject?: string; + composeService?: string; + autoUpdatePolicy?: string; + userNamespace?: string; + }; ports?: Array<{ privatePort?: number; publicPort?: number; @@ -711,6 +725,7 @@ export interface ResourceDockerMeta { message?: string; completedAt?: string; }; + stack?: string; mode?: string; desiredTasks?: number; runningTasks?: number; diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index fbe830bbc..6b25bb0c7 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1935,6 +1935,31 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo if !ct.CreatedAt.IsZero() { docker.CreatedAt = ct.CreatedAt.UTC().Format(time.RFC3339) } + if ct.StartedAt != nil && !ct.StartedAt.IsZero() { + startedAt := ct.StartedAt.UTC() + docker.StartedAt = &startedAt + } + if ct.FinishedAt != nil && !ct.FinishedAt.IsZero() { + finishedAt := ct.FinishedAt.UTC() + docker.FinishedAt = &finishedAt + } + if ct.BlockIO != nil && (ct.BlockIO.ReadBytes > 0 || ct.BlockIO.WriteBytes > 0) { + docker.BlockIO = &DockerContainerBlockIOMeta{ + ReadBytes: ct.BlockIO.ReadBytes, + WriteBytes: ct.BlockIO.WriteBytes, + } + } + if ct.Podman != nil { + docker.Podman = &DockerPodmanContainerMeta{ + PodName: strings.TrimSpace(ct.Podman.PodName), + PodID: strings.TrimSpace(ct.Podman.PodID), + Infra: ct.Podman.Infra, + ComposeProject: strings.TrimSpace(ct.Podman.ComposeProject), + ComposeService: strings.TrimSpace(ct.Podman.ComposeService), + AutoUpdatePolicy: strings.TrimSpace(ct.Podman.AutoUpdatePolicy), + UserNamespace: strings.TrimSpace(ct.Podman.UserNamespace), + } + } if len(ct.Ports) > 0 { docker.Ports = make([]DockerPortMeta, len(ct.Ports)) for i, p := range ct.Ports { diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index f747bb1ca..eecddb138 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -175,6 +175,88 @@ func TestResourceFromDockerContainerIncludesContainerID(t *testing.T) { } } +func TestResourceFromDockerContainerPreservesRuntimeMetadata(t *testing.T) { + startedAt := time.Date(2026, 6, 11, 8, 15, 30, 0, time.FixedZone("UTC-5", -5*60*60)) + finishedAt := startedAt.Add(45 * time.Minute) + container := models.DockerContainer{ + ID: "abcdef123456", + Name: "edge-web", + State: "exited", + StartedAt: &startedAt, + FinishedAt: &finishedAt, + BlockIO: &models.DockerContainerBlockIO{ + ReadBytes: 9_876_543, + WriteBytes: 1_234_567, + }, + Podman: &models.DockerPodmanContainer{ + PodName: "edge-pod", + PodID: "pod-123", + Infra: true, + ComposeProject: "orion", + ComposeService: "web", + AutoUpdatePolicy: "registry", + UserNamespace: "keep-id", + }, + } + host := models.DockerHost{ + ID: "podman-1", + Hostname: "podman-1", + } + + resource, _ := resourceFromDockerContainer(container, host) + if resource.Docker == nil { + t.Fatal("expected docker payload") + } + if got, want := resource.Docker.Runtime, "podman"; got != want { + t.Fatalf("runtime = %q, want %q", got, want) + } + if resource.Docker.StartedAt == nil { + t.Fatal("expected startedAt") + } + if got, want := *resource.Docker.StartedAt, startedAt.UTC(); !got.Equal(want) || got.Location() != time.UTC { + t.Fatalf("startedAt = %s (%s), want UTC %s", got, got.Location(), want) + } + if resource.Docker.FinishedAt == nil { + t.Fatal("expected finishedAt") + } + if got, want := *resource.Docker.FinishedAt, finishedAt.UTC(); !got.Equal(want) || got.Location() != time.UTC { + t.Fatalf("finishedAt = %s (%s), want UTC %s", got, got.Location(), want) + } + if resource.Docker.BlockIO == nil { + t.Fatal("expected block IO totals") + } + if got, want := resource.Docker.BlockIO.ReadBytes, container.BlockIO.ReadBytes; got != want { + t.Fatalf("blockIo.readBytes = %d, want %d", got, want) + } + if got, want := resource.Docker.BlockIO.WriteBytes, container.BlockIO.WriteBytes; got != want { + t.Fatalf("blockIo.writeBytes = %d, want %d", got, want) + } + if resource.Docker.Podman == nil { + t.Fatal("expected podman metadata") + } + if got, want := resource.Docker.Podman.PodName, container.Podman.PodName; got != want { + t.Fatalf("podman.podName = %q, want %q", got, want) + } + if got, want := resource.Docker.Podman.PodID, container.Podman.PodID; got != want { + t.Fatalf("podman.podId = %q, want %q", got, want) + } + if got, want := resource.Docker.Podman.Infra, container.Podman.Infra; got != want { + t.Fatalf("podman.infra = %t, want %t", got, want) + } + if got, want := resource.Docker.Podman.ComposeProject, container.Podman.ComposeProject; got != want { + t.Fatalf("podman.composeProject = %q, want %q", got, want) + } + if got, want := resource.Docker.Podman.ComposeService, container.Podman.ComposeService; got != want { + t.Fatalf("podman.composeService = %q, want %q", got, want) + } + if got, want := resource.Docker.Podman.AutoUpdatePolicy, container.Podman.AutoUpdatePolicy; got != want { + t.Fatalf("podman.autoUpdatePolicy = %q, want %q", got, want) + } + if got, want := resource.Docker.Podman.UserNamespace, container.Podman.UserNamespace; got != want { + t.Fatalf("podman.userNamespace = %q, want %q", got, want) + } +} + func TestResourceFromHostProjectsAgentHostProfile(t *testing.T) { host := models.Host{ ID: "tower-host", diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index ba73d1707..4d937c87b 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -65,6 +65,18 @@ func TestContractResourceType(t *testing.T) { }, want: ResourceTypeVM, }, + { + name: "docker app container metadata keeps app-container contract type", + resource: Resource{ + Type: ResourceTypeAppContainer, + Docker: &DockerData{ + ContainerID: "container-1", + BlockIO: &DockerContainerBlockIOMeta{ReadBytes: 1024, WriteBytes: 2048}, + Podman: &DockerPodmanContainerMeta{PodName: "edge-pod", ComposeProject: "orion"}, + }, + }, + want: ResourceTypeAppContainer, + }, { name: "network share passthrough remains canonical", resource: Resource{ diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index d18a3ab28..213fe5a2e 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -220,11 +220,14 @@ func cloneDockerData(in *DockerData) *DockerData { out.Networks = cloneDockerNetworkMetaSlice(in.Networks) out.Mounts = cloneDockerMountMetaSlice(in.Mounts) out.UpdateStatus = cloneDockerUpdateStatusMeta(in.UpdateStatus) + out.BlockIO = cloneDockerContainerBlockIOMeta(in.BlockIO) + out.Podman = cloneDockerPodmanContainerMeta(in.Podman) out.RepoTags = cloneStringSlice(in.RepoTags) out.RepoDigests = cloneStringSlice(in.RepoDigests) out.Options = cloneStringMap(in.Options) out.Subnets = cloneDockerNetworkSubnetMetaSlice(in.Subnets) out.StartedAt = cloneTimePtr(in.StartedAt) + out.FinishedAt = cloneTimePtr(in.FinishedAt) out.CompletedAt = cloneTimePtr(in.CompletedAt) out.ObjectCreatedAt = cloneTimePtr(in.ObjectCreatedAt) out.ObjectUpdatedAt = cloneTimePtr(in.ObjectUpdatedAt) @@ -244,6 +247,22 @@ func cloneDockerData(in *DockerData) *DockerData { return &out } +func cloneDockerContainerBlockIOMeta(in *DockerContainerBlockIOMeta) *DockerContainerBlockIOMeta { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneDockerPodmanContainerMeta(in *DockerPodmanContainerMeta) *DockerPodmanContainerMeta { + if in == nil { + return nil + } + out := *in + return &out +} + func clonePBSData(in *PBSData) *PBSData { if in == nil { return nil diff --git a/internal/unifiedresources/registry_merge_policy_test.go b/internal/unifiedresources/registry_merge_policy_test.go index a94d1d9e9..f5fa23f7f 100644 --- a/internal/unifiedresources/registry_merge_policy_test.go +++ b/internal/unifiedresources/registry_merge_policy_test.go @@ -128,6 +128,65 @@ func TestHostnameIPDoesNotAutoMerge(t *testing.T) { } } +func TestCloneDockerDataPreservesContainerRuntimeMetadata(t *testing.T) { + startedAt := time.Date(2026, 6, 11, 13, 15, 30, 0, time.UTC) + finishedAt := startedAt.Add(45 * time.Minute) + original := &DockerData{ + ContainerID: "container-1", + StartedAt: &startedAt, + FinishedAt: &finishedAt, + BlockIO: &DockerContainerBlockIOMeta{ + ReadBytes: 9_876_543, + WriteBytes: 1_234_567, + }, + Podman: &DockerPodmanContainerMeta{ + PodName: "edge-pod", + PodID: "pod-123", + Infra: true, + ComposeProject: "orion", + ComposeService: "web", + AutoUpdatePolicy: "registry", + UserNamespace: "keep-id", + }, + } + + cloned := cloneDockerData(original) + if cloned == nil { + t.Fatal("expected docker clone") + } + if cloned.StartedAt == nil || !cloned.StartedAt.Equal(startedAt) { + t.Fatalf("startedAt = %v, want %v", cloned.StartedAt, startedAt) + } + if cloned.FinishedAt == nil || !cloned.FinishedAt.Equal(finishedAt) { + t.Fatalf("finishedAt = %v, want %v", cloned.FinishedAt, finishedAt) + } + if cloned.BlockIO == nil { + t.Fatal("expected block IO clone") + } + if got, want := cloned.BlockIO.ReadBytes, original.BlockIO.ReadBytes; got != want { + t.Fatalf("blockIo.readBytes = %d, want %d", got, want) + } + if cloned.Podman == nil { + t.Fatal("expected podman clone") + } + if got, want := cloned.Podman.ComposeProject, original.Podman.ComposeProject; got != want { + t.Fatalf("podman.composeProject = %q, want %q", got, want) + } + + *cloned.StartedAt = cloned.StartedAt.Add(time.Hour) + cloned.BlockIO.ReadBytes = 1 + cloned.Podman.ComposeProject = "mutated" + if !original.StartedAt.Equal(startedAt) { + t.Fatalf("original startedAt mutated to %v", original.StartedAt) + } + if original.BlockIO.ReadBytes != 9_876_543 { + t.Fatalf("original blockIo.readBytes mutated to %d", original.BlockIO.ReadBytes) + } + if original.Podman.ComposeProject != "orion" { + t.Fatalf("original podman.composeProject mutated to %q", original.Podman.ComposeProject) + } +} + func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) { existing := &TrueNASData{ Hostname: "truenas-a.local", diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index af5584b48..6a94d6241 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -858,6 +858,23 @@ type DockerStorageUsageMeta struct { ReclaimableBytes int64 `json:"reclaimableBytes,omitempty"` } +// DockerContainerBlockIOMeta captures cumulative container block IO totals. +type DockerContainerBlockIOMeta struct { + ReadBytes uint64 `json:"readBytes,omitempty"` + WriteBytes uint64 `json:"writeBytes,omitempty"` +} + +// DockerPodmanContainerMeta captures Podman-specific container metadata. +type DockerPodmanContainerMeta struct { + PodName string `json:"podName,omitempty"` + PodID string `json:"podId,omitempty"` + Infra bool `json:"infra"` + ComposeProject string `json:"composeProject,omitempty"` + ComposeService string `json:"composeService,omitempty"` + AutoUpdatePolicy string `json:"autoUpdatePolicy,omitempty"` + UserNamespace string `json:"userNamespace,omitempty"` +} + // DockerData contains Docker host- and container-specific data. type DockerData struct { HostSourceID string `json:"hostSourceId,omitempty"` // raw model ID for the docker host @@ -910,15 +927,19 @@ type DockerData struct { Command *models.DockerHostCommandStatus `json:"command,omitempty"` // Container-specific fields (populated when Resource.Type == ResourceTypeAppContainer) - ContainerState string `json:"containerState,omitempty"` - Health string `json:"health,omitempty"` - RestartCount int `json:"restartCount,omitempty"` - ExitCode int `json:"exitCode,omitempty"` - Ports []DockerPortMeta `json:"ports,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Networks []DockerNetworkMeta `json:"networks,omitempty"` - Mounts []DockerMountMeta `json:"mounts,omitempty"` - UpdateStatus *DockerUpdateStatusMeta `json:"updateStatus,omitempty"` + ContainerState string `json:"containerState,omitempty"` + Health string `json:"health,omitempty"` + RestartCount int `json:"restartCount,omitempty"` + ExitCode int `json:"exitCode,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + BlockIO *DockerContainerBlockIOMeta `json:"blockIo,omitempty"` + Podman *DockerPodmanContainerMeta `json:"podman,omitempty"` + Ports []DockerPortMeta `json:"ports,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Networks []DockerNetworkMeta `json:"networks,omitempty"` + Mounts []DockerMountMeta `json:"mounts,omitempty"` + UpdateStatus *DockerUpdateStatusMeta `json:"updateStatus,omitempty"` // Service-specific fields (populated when Resource.Type == ResourceTypeDockerService) ServiceID string `json:"serviceId,omitempty"` @@ -967,7 +988,6 @@ type DockerData struct { CurrentState string `json:"currentState,omitempty"` Error string `json:"error,omitempty"` Message string `json:"message,omitempty"` - StartedAt *time.Time `json:"startedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` // Swarm-node-specific fields (populated when Resource.Type == ResourceTypeDockerSwarmNode)