Preserve Docker container metadata in unified resources

This commit is contained in:
rcourtman 2026-06-11 21:03:14 +01:00
parent cc684ab689
commit d38b8a6d00
15 changed files with 489 additions and 41 deletions

View file

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

View file

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

View file

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

View file

@ -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<Resource['docker']
if (resource.type !== 'app-container') return null;
const docker = resource.docker;
if (!docker) return null;
if (!docker.containerId && !docker.containerState && !docker.image) return null;
if (
!docker.containerId &&
!docker.containerState &&
!docker.image &&
!docker.startedAt &&
!docker.finishedAt &&
!docker.blockIo &&
!docker.podman
) {
return null;
}
return docker;
};
const trimmedDockerValue = (value: string | undefined): string => (value || '').trim();
const composeLabelValue = (
labels: Record<string, string> | undefined,
suffix: 'project' | 'service',
@ -35,19 +47,35 @@ const composeLabelValue = (
''
).trim();
const dockerCreatedAtMillis = (docker: NonNullable<Resource['docker']>): 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<Resource['docker']> }> = (
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: NonNullable<Resource['d
<td class="px-2 py-1 text-muted">Restarts</td>
<td
class={`px-2 py-1 text-right font-medium ${
(restartCount() ?? 0) > 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<Resource['d
</tr>
)}
</Show>
<Show when={composeLabelValue(labels(), 'project')}>
<Show when={startedAt()}>
{(started) => (
<tr>
<td class="px-2 py-1 text-muted">Started</td>
<td
class="px-2 py-1 text-right font-medium text-base-content"
title={new Date(started()).toLocaleString()}
>
{formatRelativeTime(started())}
</td>
</tr>
)}
</Show>
<Show when={finishedAt()}>
{(finished) => (
<tr>
<td class="px-2 py-1 text-muted">Finished</td>
<td
class="px-2 py-1 text-right font-medium text-base-content"
title={new Date(finished()).toLocaleString()}
>
{formatRelativeTime(finished())}
</td>
</tr>
)}
</Show>
<Show when={podmanPodName()}>
<tr>
<td class="px-2 py-1 text-muted">Compose project</td>
<td class="px-2 py-1 text-right font-medium text-base-content">
{composeLabelValue(labels(), 'project')}
<td class="px-2 py-1 text-muted">Podman pod</td>
<td class="px-2 py-1 text-right font-medium text-base-content" title={podmanPodName()}>
<span class="block truncate">{podmanPodName()}</span>
</td>
</tr>
</Show>
<Show when={composeLabelValue(labels(), 'service')}>
<Show when={podmanPodId()}>
<tr>
<td class="px-2 py-1 text-muted">Compose service</td>
<td class="px-2 py-1 text-right font-medium text-base-content">
{composeLabelValue(labels(), 'service')}
<td class="px-2 py-1 text-muted">Podman pod ID</td>
<td class="px-2 py-1 text-right font-medium text-base-content" title={podmanPodId()}>
<span class="block truncate">{podmanPodId()}</span>
</td>
</tr>
</Show>
<Show when={typeof podman()?.infra === 'boolean'}>
<tr>
<td class="px-2 py-1 text-muted">Podman infra</td>
<td class="px-2 py-1 text-right font-medium text-base-content">
{podman()?.infra ? 'Yes' : 'No'}
</td>
</tr>
</Show>
<Show when={composeProject()}>
<tr>
<td class="px-2 py-1 text-muted">Compose project</td>
<td class="px-2 py-1 text-right font-medium text-base-content">{composeProject()}</td>
</tr>
</Show>
<Show when={composeService()}>
<tr>
<td class="px-2 py-1 text-muted">Compose service</td>
<td class="px-2 py-1 text-right font-medium text-base-content">{composeService()}</td>
</tr>
</Show>
<Show when={autoUpdatePolicy()}>
<tr>
<td class="px-2 py-1 text-muted">Auto-update</td>
<td class="px-2 py-1 text-right font-medium text-base-content">{autoUpdatePolicy()}</td>
</tr>
</Show>
<Show when={userNamespace()}>
<tr>
<td class="px-2 py-1 text-muted">User namespace</td>
<td class="px-2 py-1 text-right font-medium text-base-content" title={userNamespace()}>
<span class="block truncate">{userNamespace()}</span>
</td>
</tr>
</Show>
<Show when={blockReadBytes()}>
{(readBytes) => (
<tr>
<td class="px-2 py-1 text-muted">Block I/O read</td>
<td class="px-2 py-1 text-right font-medium text-base-content">
{formatBytes(readBytes())}
</td>
</tr>
)}
</Show>
<Show when={blockWriteBytes()}>
{(writeBytes) => (
<tr>
<td class="px-2 py-1 text-muted">Block I/O write</td>
<td class="px-2 py-1 text-right font-medium text-base-content">
{formatBytes(writeBytes())}
</td>
</tr>
)}
</Show>
<Show when={labelEntries().length > 0}>
<tr>
<td class="px-2 py-1 align-top text-muted">Labels</td>

View file

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

View file

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

View file

@ -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<Resource['docker']> & { 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 ?? []),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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