mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Preserve Docker container metadata in unified resources
This commit is contained in:
parent
cc684ab689
commit
d38b8a6d00
15 changed files with 489 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? []),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue