Add native Docker containers table

This commit is contained in:
rcourtman
2026-05-24 17:41:35 +01:00
parent a76a75bdf6
commit 223b11185f
14 changed files with 715 additions and 729 deletions
@@ -170,10 +170,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. Docker / Podman native
subsections now include engine storage usage, Swarm node inventory, and
metadata-only Swarm secret/config inventory where the documented Docker APIs
report those resources; Podman-only libpod pod inventory must not be
represented until the collector has a libpod-native source. Kubernetes config
subsections now include runtime containers, engine storage usage, Swarm node
inventory, and metadata-only Swarm secret/config inventory where the documented
Docker APIs report those resources; Podman-only libpod pod inventory must not
be represented until the collector has a libpod-native source. Kubernetes config
inventory must preserve the same trust boundary for metadata-only ConfigMap
and Secret rows: the shared table wording may indicate metadata-only inventory,
but must not imply payload fields were read when the agent used Kubernetes
@@ -200,8 +200,12 @@ Kubernetes events inventory follows that same primitive boundary while the
unified-resource owner supplies Event type, reason, involved-object, count,
observed-time, and message columns.
Docker / Podman inventory follows that same primitive boundary while the
unified-resource owner supplies API-object-specific image, volume, network,
Swarm node, task, secret, and config columns through dedicated native tables.
unified-resource owner supplies API-object-specific container, image, volume,
network, Swarm node, task, secret, and config columns through dedicated native
tables. The Docker containers tab must use the native
`DockerContainersTable` for container state, health, restart, image, port,
network, mount, update, and host/runtime columns rather than embedding
`WorkloadsSurface`.
1. `frontend-modern/src/components/Settings/APIAccessPanel.tsx` shared with `security-privacy`: the API Access settings intro is both a security/privacy token-management trust surface and a canonical settings-shell presentation boundary.
The panel may own shell placement and local action layout, but
@@ -5060,6 +5060,7 @@
"frontend-modern/src/components/PMG/PMGInstancePanel.tsx",
"frontend-modern/src/components/PMG/ServiceHealthBadge.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
@@ -5208,6 +5209,7 @@
"frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx",
"frontend-modern/src/components/PMG/PMGInstancePanel.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
@@ -55,95 +55,96 @@ cross-source deduplication.
31. `frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx`
32. `frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx`
33. `frontend-modern/src/features/docker/DockerConfigsTable.tsx`
34. `frontend-modern/src/features/docker/DockerImagesTable.tsx`
35. `frontend-modern/src/features/docker/DockerNativeTableShared.tsx`
36. `frontend-modern/src/features/docker/DockerNetworksTable.tsx`
37. `frontend-modern/src/features/docker/DockerPageSurface.tsx`
38. `frontend-modern/src/features/docker/DockerSecretsTable.tsx`
39. `frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx`
40. `frontend-modern/src/features/docker/DockerTasksTable.tsx`
41. `frontend-modern/src/features/docker/DockerVolumesTable.tsx`
42. `frontend-modern/src/features/docker/dockerPageModel.ts`
43. `frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx`
44. `frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx`
45. `frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx`
46. `frontend-modern/src/components/PMG/MailGateway.tsx`
47. `frontend-modern/src/components/PMG/PMGInstancePanel.tsx`
48. `frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx`
49. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`
50. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
51. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
52. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
53. `frontend-modern/src/components/Infrastructure/resourceBadges.ts`
54. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
55. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
56. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
57. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
58. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
59. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
60. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
61. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
62. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
63. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
64. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
65. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
66. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
67. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
68. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
69. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
70. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
71. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
72. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
73. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
74. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
75. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
76. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
77. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
78. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
79. `frontend-modern/src/utils/agentResources.ts`
80. `frontend-modern/src/utils/canonicalResourceTypes.ts`
81. `frontend-modern/src/utils/resourceBadgePresentation.ts`
82. `frontend-modern/src/utils/resourceChangePresentation.ts`
83. `frontend-modern/src/utils/actionAuditPresentation.ts`
84. `frontend-modern/src/utils/resourceCorrelationPresentation.ts`
85. `frontend-modern/src/utils/resourcePlatformData.ts`
86. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
87. `frontend-modern/src/utils/resourceStateAdapters.ts`
88. `frontend-modern/src/utils/resourceTypeCompat.ts`
89. `frontend-modern/src/utils/resourceTypePresentation.ts`
90. `frontend-modern/src/utils/serviceHealthPresentation.ts`
91. `frontend-modern/src/utils/sourceTypePresentation.ts`
92. `frontend-modern/src/utils/workloadTypePresentation.ts`
93. `frontend-modern/src/components/PMG/ServiceHealthBadge.tsx`
94. `frontend-modern/src/utils/resourceIdentity.ts`
95. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
96. `frontend-modern/src/hooks/useUnifiedResources.ts`
97. `frontend-modern/src/types/resource.ts`
98. `frontend-modern/src/utils/sourcePlatforms.ts`
99. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
100. `internal/unifiedresources/kubernetes_metric_ids.go`
101. `internal/unifiedresources/policy_posture.go`
102. `internal/unifiedresources/clone.go`
103. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerPresentation.ts`
104. `internal/unifiedresources/storage_consumers.go`
105. `frontend-modern/src/features/agents/agentsPageModel.ts`
106. `frontend-modern/src/features/agents/AgentsPageSurface.tsx`
107. `frontend-modern/src/features/agents/AgentsMachinesTable.tsx`
108. `internal/platformsupport/manifest_generated.go`
109. `frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx`
110. `frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx`
111. `frontend-modern/src/features/kubernetes/kubernetesPageModel.ts`
112. `frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx`
113. `frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx`
114. `frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx`
115. `frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx`
116. `frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx`
117. `frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx`
118. `frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx`
119. `frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx`
120. `frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx`
121. `frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx`
122. `frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx`
34. `frontend-modern/src/features/docker/DockerContainersTable.tsx`
35. `frontend-modern/src/features/docker/DockerImagesTable.tsx`
36. `frontend-modern/src/features/docker/DockerNativeTableShared.tsx`
37. `frontend-modern/src/features/docker/DockerNetworksTable.tsx`
38. `frontend-modern/src/features/docker/DockerPageSurface.tsx`
39. `frontend-modern/src/features/docker/DockerSecretsTable.tsx`
40. `frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx`
41. `frontend-modern/src/features/docker/DockerTasksTable.tsx`
42. `frontend-modern/src/features/docker/DockerVolumesTable.tsx`
43. `frontend-modern/src/features/docker/dockerPageModel.ts`
44. `frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx`
45. `frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx`
46. `frontend-modern/src/components/PMG/PMGInstanceDrawer.tsx`
47. `frontend-modern/src/components/PMG/MailGateway.tsx`
48. `frontend-modern/src/components/PMG/PMGInstancePanel.tsx`
49. `frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx`
50. `frontend-modern/src/components/Infrastructure/ResourceFacetSummary.tsx`
51. `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx`
52. `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx`
53. `frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx`
54. `frontend-modern/src/components/Infrastructure/resourceBadges.ts`
55. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`
56. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx`
57. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
58. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
59. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
60. `frontend-modern/src/components/Infrastructure/unifiedResourceTableStateModel.ts`
61. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
62. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts`
63. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts`
64. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts`
65. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts`
66. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
67. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts`
68. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
69. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
70. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts`
71. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
72. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
73. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
74. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
75. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
76. `frontend-modern/src/features/infrastructure/infrastructurePageModel.ts`
77. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
78. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
79. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
80. `frontend-modern/src/utils/agentResources.ts`
81. `frontend-modern/src/utils/canonicalResourceTypes.ts`
82. `frontend-modern/src/utils/resourceBadgePresentation.ts`
83. `frontend-modern/src/utils/resourceChangePresentation.ts`
84. `frontend-modern/src/utils/actionAuditPresentation.ts`
85. `frontend-modern/src/utils/resourceCorrelationPresentation.ts`
86. `frontend-modern/src/utils/resourcePlatformData.ts`
87. `frontend-modern/src/utils/resourcePolicyPresentation.ts`
88. `frontend-modern/src/utils/resourceStateAdapters.ts`
89. `frontend-modern/src/utils/resourceTypeCompat.ts`
90. `frontend-modern/src/utils/resourceTypePresentation.ts`
91. `frontend-modern/src/utils/serviceHealthPresentation.ts`
92. `frontend-modern/src/utils/sourceTypePresentation.ts`
93. `frontend-modern/src/utils/workloadTypePresentation.ts`
94. `frontend-modern/src/components/PMG/ServiceHealthBadge.tsx`
95. `frontend-modern/src/utils/resourceIdentity.ts`
96. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerIdentityModel.ts`
97. `frontend-modern/src/hooks/useUnifiedResources.ts`
98. `frontend-modern/src/types/resource.ts`
99. `frontend-modern/src/utils/sourcePlatforms.ts`
100. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
101. `internal/unifiedresources/kubernetes_metric_ids.go`
102. `internal/unifiedresources/policy_posture.go`
103. `internal/unifiedresources/clone.go`
104. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerPresentation.ts`
105. `internal/unifiedresources/storage_consumers.go`
106. `frontend-modern/src/features/agents/agentsPageModel.ts`
107. `frontend-modern/src/features/agents/AgentsPageSurface.tsx`
108. `frontend-modern/src/features/agents/AgentsMachinesTable.tsx`
109. `internal/platformsupport/manifest_generated.go`
110. `frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx`
111. `frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx`
112. `frontend-modern/src/features/kubernetes/kubernetesPageModel.ts`
113. `frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx`
114. `frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx`
115. `frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx`
116. `frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx`
117. `frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx`
118. `frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx`
119. `frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx`
120. `frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx`
121. `frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx`
122. `frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx`
123. `frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx`
## Shared Boundaries
@@ -188,10 +189,10 @@ time, and message instead of routing event rows through the generic controller
inventory table.
Docker / Podman runtime inventory is a unified-resource consumer boundary: the
container runtime page must render Engine image, volume, network, Swarm node,
task, secret, and config resources through API-object-native tables that
preserve the fields documented by the Docker Engine API instead of routing those
rows through a variant-switched generic inventory table.
container runtime page must render Engine container, image, volume, network,
Swarm node, task, secret, and config resources through API-object-native tables
that preserve the fields documented by the Docker Engine API instead of routing
those rows through a variant-switched generic inventory table.
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
2. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` shared with `performance-and-scalability`: the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary.
@@ -288,15 +289,20 @@ rows through a variant-switched generic inventory table.
preserving service name, boot enablement, runtime state, and process IDs
through clone, merge, transport, and frontend decode paths.
Docker / Podman inventory extends that same canonical type contract beyond
containers and Swarm services. Runtime image, volume, network, task, Swarm
node, Swarm secret, Swarm config, and storage-usage evidence must enter
through `DockerData` and the typed Docker resource records (`docker-image`,
`docker-volume`, `docker-network`, `docker-task`, `docker-swarm-node`,
`docker-secret`, `docker-config`) rather than being inferred inside the
container page. Swarm node records must preserve node id, hostname, role,
availability, state, manager reachability, manager address, leader state,
engine version, platform, resource capacity, labels, and engine labels under
the owning Docker host or Swarm cluster identity.
generic workload rows and Swarm services. Runtime containers must preserve
Docker Engine container identity, owning host/runtime context, image,
state/health/restart/update status, ports, networks, and mounts through
`DockerData` so `/docker/containers` can use the native
`DockerContainersTable` rather than `WorkloadsSurface`. Runtime image,
volume, network, task, Swarm node, Swarm secret, Swarm config, and
storage-usage evidence must enter through `DockerData` and the typed Docker
resource records (`docker-image`, `docker-volume`, `docker-network`,
`docker-task`, `docker-swarm-node`, `docker-secret`, `docker-config`) rather
than being inferred inside the container page. Swarm node records must
preserve node id, hostname, role, availability, state, manager reachability,
manager address, leader state, engine version, platform, resource capacity,
labels, and engine labels under the owning Docker host or Swarm cluster
identity.
Swarm secret and config records are metadata projections only. They may
preserve id, name, labels, driver/template metadata, and timestamps, but
Docker secret/config payload bytes are outside the unified-resource contract.
@@ -2092,13 +2098,16 @@ Docker platform subtabs now follow canonical resource evidence as well:
exist, and `Swarm nodes` is backed by canonical `docker-swarm-node` resources
from Docker manager node inventory. Inactive standalone Docker Swarm metadata
is not a tab, host role, or service-surface signal.
The frontend Docker facet contract covers both host runtime telemetry and
Swarm service/task/node projection. Host resources may expose runtime/version,
OS, temperature, uptime, container/image/volume/network/node counts, update
state, command metadata, engine storage usage buckets, and Swarm
local-state/control evidence through `ResourceDockerMeta`; Docker platform
pages must consume those canonical fields rather than keeping page-local
host-runtime shape aliases.
The frontend Docker facet contract covers host runtime telemetry, runtime
container projection, and Swarm service/task/node projection. Host resources
may expose runtime/version, OS, temperature, uptime,
container/image/volume/network/node counts, update state, command metadata,
engine storage usage buckets, and Swarm local-state/control evidence through
`ResourceDockerMeta`; app-container resources may expose container id, host
name, runtime/version, image/digest, container state, health, restart/exit
counts, published ports, attached networks, mounts, and image-update status.
Docker platform pages must consume those canonical fields rather than keeping
page-local host-runtime or container-runtime shape aliases.
Canonical resources now also carry `platformScopes`, the normalized
platform-page membership list consumed by workload filters. `platformType`
remains the primary display/source family, while `platformScopes` captures
@@ -0,0 +1,389 @@
import { For, Show, type Component } from 'solid-js';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
DockerResourceNameCell,
dockerHostName,
dockerJoinValues,
dockerNumberValue,
dockerTextValue,
type DockerNativeTableProps,
} from './DockerNativeTableShared';
import type { Resource } from '@/types/resource';
type DockerPort = NonNullable<NonNullable<Resource['docker']>['ports']>[number];
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
const numberSearchValue = (value: number | undefined): string | undefined =>
typeof value === 'number' ? String(value) : undefined;
const portLabel = (port: DockerPort): string => {
const protocol = asTrimmedString(port.protocol)?.toLowerCase() || 'tcp';
const privatePort = numberSearchValue(port.privatePort);
const publicPort = numberSearchValue(port.publicPort);
const ip = asTrimmedString(port.ip);
if (privatePort && publicPort) {
return `${ip ? `${ip}:` : ''}${publicPort}->${privatePort}/${protocol}`;
}
if (privatePort) return `${privatePort}/${protocol}`;
if (publicPort) return `${ip ? `${ip}:` : ''}${publicPort}/${protocol}`;
return protocol;
};
const portsSummary = (resource: Resource): string =>
dockerJoinValues(resource.docker?.ports?.map((port) => portLabel(port)));
const networkLabel = (network: DockerNetwork): string => {
const name = asTrimmedString(network.name);
const address = asTrimmedString(network.ipv4) || asTrimmedString(network.ipv6);
if (name && address) return `${name} ${address}`;
return name || address || '';
};
const networksSummary = (resource: Resource): string =>
dockerJoinValues(resource.docker?.networks?.map((network) => networkLabel(network)));
const mountLabel = (mount: DockerMount): string => {
const type = asTrimmedString(mount.type);
const destination = asTrimmedString(mount.destination);
const source = asTrimmedString(mount.source);
const mode = asTrimmedString(mount.mode) || (mount.rw === false ? 'ro' : '');
const endpoint = destination || source;
if (!endpoint) return type || '';
const prefix = type ? `${type}:` : '';
const suffix = mode ? ` (${mode})` : '';
return `${prefix}${endpoint}${suffix}`;
};
const mountsSummary = (resource: Resource): string =>
dockerJoinValues(resource.docker?.mounts?.map((mount) => mountLabel(mount)));
const runtimeSummary = (resource: Resource): string => {
const runtime = asTrimmedString(resource.docker?.runtime);
const version = asTrimmedString(
resource.docker?.runtimeVersion || resource.docker?.dockerVersion,
);
if (runtime && version) return `${runtime} ${version}`;
return runtime || version || '—';
};
const containerState = (resource: Resource): string =>
dockerTextValue(resource.docker?.containerState || resource.status);
const updateStatusLabel = (resource: Resource): string => {
const update = resource.docker?.updateStatus;
if (!update) return '—';
if (asTrimmedString(update.error)) return 'Error';
return update.updateAvailable ? 'Available' : 'Current';
};
const updateStatusTitle = (resource: Resource): string => {
const update = resource.docker?.updateStatus;
if (!update) return 'No image update check reported';
return dockerJoinValues(
[
updateStatusLabel(resource),
update.error,
update.currentDigest ? `current ${update.currentDigest}` : undefined,
update.latestDigest ? `latest ${update.latestDigest}` : undefined,
update.lastChecked ? `checked ${update.lastChecked}` : undefined,
],
updateStatusLabel(resource),
);
};
const dockerContainerSearchCandidates = (resource: Resource): Array<string | undefined> => [
resource.name,
resource.displayName,
resource.id,
resource.parentName,
resource.platformId,
resource.platformType,
resource.agent?.hostname,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
resource.docker?.containerId,
resource.docker?.displayName,
resource.docker?.hostname,
resource.docker?.runtime,
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.image,
resource.docker?.imageId,
resource.docker?.containerState,
resource.docker?.health,
numberSearchValue(resource.docker?.restartCount),
numberSearchValue(resource.docker?.exitCode),
updateStatusLabel(resource),
resource.docker?.updateStatus?.error,
resource.docker?.updateStatus?.currentDigest,
resource.docker?.updateStatus?.latestDigest,
...(resource.docker?.ports?.flatMap((port) => [
portLabel(port),
port.ip,
numberSearchValue(port.privatePort),
numberSearchValue(port.publicPort),
port.protocol,
]) ?? []),
...(resource.docker?.networks?.flatMap((network) => [
networkLabel(network),
network.name,
network.ipv4,
network.ipv6,
]) ?? []),
...(resource.docker?.mounts?.flatMap((mount) => [
mountLabel(mount),
mount.type,
mount.source,
mount.destination,
mount.mode,
mount.rw === false ? 'read-only' : mount.rw === true ? 'read-write' : undefined,
]) ?? []),
...(resource.tags ?? []),
];
const matchesDockerContainerSearch = (resource: Resource, search: string): boolean => {
const needle = search.trim().toLowerCase();
if (!needle) return true;
return dockerContainerSearchCandidates(resource)
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.some((value) => value.toLowerCase().includes(needle));
};
const filterDockerContainerResources = (
resources: Resource[],
search: string,
status: PlatformResourceStatusFilter,
): Resource[] =>
filterPlatformResources(resources, '', status).filter((resource) =>
matchesDockerContainerSearch(resource, search),
);
export const DockerContainersTable: Component<DockerNativeTableProps> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterDockerContainerResources,
});
return (
<Show
when={props.resources.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search containers"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="containers"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No containers match current filters"
description="Adjust the search or status filter to see more Docker containers."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Containers'} />
<Table class="min-w-[790px] table-fixed text-xs md:min-w-[1680px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-[220px]`}>
Container
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[140px]`}
>
Host
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[130px]`}
>
Runtime
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[260px]`}>
Image
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[100px]`}>
State
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[100px]`}
>
Health
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} w-[110px]`}
>
Restarts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[190px]`}
>
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[150px]`}
>
Networks
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[180px]`}
>
Mounts
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[100px]`}>
Updates
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(resource) => {
const image = () => dockerTextValue(resource.docker?.image);
const state = () => containerState(resource);
const health = () => dockerTextValue(resource.docker?.health);
const runtime = () => runtimeSummary(resource);
const host = () => dockerHostName(resource);
const ports = () => portsSummary(resource);
const networks = () => networksSummary(resource);
const mounts = () => mountsSummary(resource);
const updates = () => updateStatusLabel(resource);
const updateTitle = () => updateStatusTitle(resource);
return (
<TableRow
class="text-[11px] sm:text-xs"
data-docker-container-row={resource.id}
>
<DockerResourceNameCell resource={resource} />
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={host()}>
{host()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={runtime()}>
{runtime()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="block max-w-full truncate" title={image()}>
{image()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{state()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{health()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{dockerNumberValue(resource.docker?.restartCount)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="block max-w-full truncate font-mono text-[11px]"
title={ports()}
>
{ports()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={networks()}>
{networks()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={mounts()}>
{mounts()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="block max-w-full truncate" title={updateTitle()}>
{updates()}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default DockerContainersTable;
@@ -1,10 +1,6 @@
import { useLocation } from '@solidjs/router';
import ContainerIcon from 'lucide-solid/icons/container';
import { Show, createMemo } from 'solid-js';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PlatformErrorState,
@@ -12,8 +8,8 @@ import {
PlatformTableEmptyState,
PlatformTableLoadingState,
} from '@/features/platformPage/sharedPlatformPage';
import { APP_CONTAINER_COLUMN_LABEL_OVERRIDES } from '@/features/platformPage/appContainerColumns';
import { DockerConfigsTable } from './DockerConfigsTable';
import { DockerContainersTable } from './DockerContainersTable';
import { DockerHostsTable } from './DockerHostsTable';
import { DockerImagesTable } from './DockerImagesTable';
import { DockerNetworksTable } from './DockerNetworksTable';
@@ -23,32 +19,10 @@ import { DockerStorageUsageTable } from './DockerStorageUsageTable';
import { DockerSwarmNodesTable } from './DockerSwarmNodesTable';
import { DockerTasksTable } from './DockerTasksTable';
import { DockerVolumesTable } from './DockerVolumesTable';
import {
DOCKER_TAB_SPECS,
buildDockerPageModel,
buildDockerContainerDefaultHiddenColumnIds,
buildDockerWorkloadGroupLabelBadges,
filterDockerHosts,
filterDockerServices,
type DockerPageTabId,
} from './dockerPageModel';
import { DOCKER_TAB_SPECS, buildDockerPageModel, type DockerPageTabId } from './dockerPageModel';
const DOCKER_RESOURCE_QUERY =
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node,docker-secret,docker-config';
const DOCKER_PLATFORM_FILTER = 'docker';
const DOCKER_WORKLOAD_FORCED_VIEW_MODE = 'app-container';
const DOCKER_WORKLOAD_DEFAULT_SORT_KEY = 'name';
const DOCKER_WORKLOAD_COLUMN_SCOPE = 'docker-runtime-containers';
const DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES = {
...APP_CONTAINER_COLUMN_LABEL_OVERRIDES,
context: 'Host',
} as const;
const DOCKER_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
{ value: 'all', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'degraded', label: 'Attention' },
{ value: 'stopped', label: 'Stopped' },
];
const VALID_TABS = new Set<DockerPageTabId>(DOCKER_TAB_SPECS.map((tab) => tab.id));
const dockerIcon = () => <ContainerIcon class="h-6 w-6 text-slate-400" />;
@@ -65,49 +39,7 @@ export function DockerPageSurface() {
return segment && VALID_TABS.has(segment) ? segment : 'overview';
});
const model = createMemo(() => buildDockerPageModel(resources()));
const dockerWorkloadDefaultHiddenColumns = createMemo(() =>
buildDockerContainerDefaultHiddenColumnIds(model().containers),
);
const dockerWorkloadGroupLabelBadges = createMemo(() =>
buildDockerWorkloadGroupLabelBadges(model().hosts),
);
const workloadsState = useWorkloadsState({
vms: [],
containers: [],
nodes: [],
useWorkloads: true,
embedded: true,
tableOnly: true,
forcedPlatform: DOCKER_PLATFORM_FILTER,
forcedViewMode: DOCKER_WORKLOAD_FORCED_VIEW_MODE,
defaultSortKey: DOCKER_WORKLOAD_DEFAULT_SORT_KEY,
showFilterToolbar: true,
suppressPlatformFilter: true,
allowEmbeddedScopeFilters: true,
columnVisibilityStorageScope: DOCKER_WORKLOAD_COLUMN_SCOPE,
additionalDefaultHiddenColumnIds: dockerWorkloadDefaultHiddenColumns(),
columnLabelOverrides: DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES,
groupLabelBadges: dockerWorkloadGroupLabelBadges(),
compactGroupHeaders: true,
groupNodeDrawerMode: 'disabled',
});
const pageFilters = createMemo(() => ({
containerRuntime: workloadsState.containerRuntime().trim() || null,
searchTerm: workloadsState.search().trim() || null,
selectedHostScope: workloadsState.selectedNode(),
statusMode: workloadsState.statusMode(),
}));
const filteredHosts = createMemo(() => filterDockerHosts(model().hosts, pageFilters()));
const filteredServices = createMemo(() => filterDockerServices(model().services, pageFilters()));
const showSharedFilterToolbar = createMemo(
() =>
workloadsState.surfaceConnected() &&
workloadsState.surfaceInitialDataReceived() &&
workloadsState.allGuests().length > 0,
);
const showServicesSection = createMemo(
() => pageFilters().containerRuntime?.toLowerCase() !== 'podman' && model().services.length > 0,
);
const showServicesSection = createMemo(() => model().services.length > 0);
return (
<div data-testid="docker-page" class="space-y-3">
@@ -148,17 +80,21 @@ export function DockerPageSurface() {
>
<Show when={activeTab() === 'overview'}>
<DockerOverview
showSharedFilterToolbar={showSharedFilterToolbar()}
workloadsState={workloadsState}
hosts={filteredHosts()}
hosts={model().hosts}
hostSourceCount={model().hosts.length}
filteredServices={filteredServices()}
containers={model().containers}
services={model().services}
serviceSourceCount={model().services.length}
showServicesSection={showServicesSection()}
/>
</Show>
<Show when={activeTab() === 'containers'}>
<DockerContainers workloadsState={workloadsState} />
<DockerContainersTable
resources={model().containers}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman containers"
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
</Show>
<Show when={activeTab() === 'images'}>
<DockerImagesTable
@@ -186,7 +122,7 @@ export function DockerPageSurface() {
</Show>
<Show when={activeTab() === 'storage'}>
<DockerStorageUsageTable
hosts={filteredHosts()}
hosts={model().hosts}
sourceCount={model().hosts.length}
emptyIcon={dockerIcon()}
emptyTitle="No engine storage usage"
@@ -243,87 +179,16 @@ export function DockerPageSurface() {
export default DockerPageSurface;
type DockerWorkloadsState = ReturnType<typeof useWorkloadsState>;
function DockerWorkloadsFilter(props: { workloadsState: DockerWorkloadsState }) {
return (
<div data-summary-clear-ignore>
<WorkloadsFilter
search={props.workloadsState.search}
setSearch={props.workloadsState.setSearch}
viewMode={props.workloadsState.viewMode}
setViewMode={props.workloadsState.setViewMode}
statusMode={props.workloadsState.statusMode}
setStatusMode={props.workloadsState.setStatusMode}
groupingMode={props.workloadsState.groupingMode}
setGroupingMode={props.workloadsState.setGroupingMode}
defaultSortKey={DOCKER_WORKLOAD_DEFAULT_SORT_KEY}
setSortKey={props.workloadsState.setSortKey}
setSortDirection={props.workloadsState.setSortDirection}
onBeforeAutoFocus={props.workloadsState.handleBeforeAutoFocus}
ariaLabel="Container runtime workload filters"
searchPlaceholder="Search containers by name, image, host, or runtime"
searchEmptyMessage="Recent Docker container searches appear here."
statusOptions={DOCKER_WORKLOAD_STATUS_OPTIONS}
columnVisibility={props.workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={props.workloadsState.containerRuntimeFilterConfig()}
hostFilter={undefined}
platformFilter={undefined}
namespaceFilter={undefined}
suppressTypeFilter
metricDisplayMode={props.workloadsState.workloadMetricDisplayMode}
setMetricDisplayMode={props.workloadsState.setWorkloadMetricDisplayMode}
metricHistoryRange={props.workloadsState.workloadMetricHistoryRange}
setMetricHistoryRange={props.workloadsState.setWorkloadMetricHistoryRange}
forcedPlatform={DOCKER_PLATFORM_FILTER}
pinnedSelectionActive={() =>
Boolean(
props.workloadsState.selectedGuestId() ||
props.workloadsState.focusedSummaryWorkloadGroupId(),
)
}
onClearPinnedSelection={props.workloadsState.clearPinnedSummaryScope}
/>
</div>
);
}
function DockerContainers(props: { workloadsState: DockerWorkloadsState }) {
return (
<div class="space-y-4">
<DockerWorkloadsFilter workloadsState={props.workloadsState} />
<WorkloadsSurface
state={props.workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
groupNodeDrawerMode="disabled"
emptyStateTitle="No Docker or Podman containers"
emptyStateDescription="Containers appear here when a Docker or Podman host reports workload inventory."
/>
</div>
);
}
function DockerOverview(props: {
showSharedFilterToolbar: boolean;
workloadsState: DockerWorkloadsState;
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
hostSourceCount: number;
filteredServices: ReturnType<typeof buildDockerPageModel>['services'];
containers: ReturnType<typeof buildDockerPageModel>['containers'];
services: ReturnType<typeof buildDockerPageModel>['services'];
serviceSourceCount: number;
showServicesSection: boolean;
}) {
return (
<div class="space-y-4">
<Show when={props.showSharedFilterToolbar}>
<DockerWorkloadsFilter workloadsState={props.workloadsState} />
</Show>
<DockerHostsTable
resources={props.hosts}
sourceCount={props.hostSourceCount}
@@ -332,23 +197,16 @@ function DockerOverview(props: {
emptyDescription="Container hosts appear here once a Pulse agent registers them."
showToolbar={false}
/>
<WorkloadsSurface
state={props.workloadsState}
vms={[]}
containers={[]}
nodes={[]}
useWorkloads
embedded
tableOnly
forcedPlatform={DOCKER_PLATFORM_FILTER}
forcedViewMode={DOCKER_WORKLOAD_FORCED_VIEW_MODE}
groupNodeDrawerMode="disabled"
emptyStateTitle="No Docker or Podman containers"
emptyStateDescription="Containers appear here when a Docker or Podman host reports workload inventory."
<DockerContainersTable
resources={props.containers}
emptyIcon={dockerIcon()}
emptyTitle="No Docker or Podman containers"
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
showToolbar={false}
/>
<Show when={props.showServicesSection}>
<DockerServicesTable
resources={props.filteredServices}
resources={props.services}
sourceCount={props.serviceSourceCount}
emptyIcon={dockerIcon()}
emptyTitle="No Swarm services"
@@ -2,6 +2,7 @@ import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { DockerContainersTable } from '../DockerContainersTable';
import { DockerConfigsTable } from '../DockerConfigsTable';
import { DockerImagesTable } from '../DockerImagesTable';
import { DockerNetworksTable } from '../DockerNetworksTable';
@@ -33,6 +34,66 @@ afterEach(() => {
});
describe('Docker native tables', () => {
it('renders Docker container API fields', () => {
render(() => (
<DockerContainersTable
resources={[
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: {
hostname: 'edge-01',
runtime: 'docker',
runtimeVersion: '27.5.1',
image: 'nginx:latest',
containerState: 'running',
health: 'healthy',
restartCount: 2,
ports: [{ ip: '0.0.0.0', publicPort: 8080, privatePort: 80, protocol: 'tcp' }],
networks: [{ name: 'frontend', ipv4: '172.18.0.2' }],
mounts: [
{
type: 'volume',
source: 'nginx-html',
destination: '/usr/share/nginx/html',
mode: 'rw',
rw: true,
},
],
updateStatus: {
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:latest',
lastChecked: '2026-05-24T13:00:00Z',
},
},
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.getByText('Runtime')).toBeInTheDocument();
expect(screen.getByText('Restarts')).toBeInTheDocument();
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('edge-web')).toBeInTheDocument();
expect(screen.getByText('edge-01')).toBeInTheDocument();
expect(screen.getByText('docker 27.5.1')).toBeInTheDocument();
expect(screen.getByText('nginx:latest')).toBeInTheDocument();
expect(screen.getByText('healthy')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('0.0.0.0:8080->80/tcp')).toBeInTheDocument();
expect(screen.getByText('frontend 172.18.0.2')).toBeInTheDocument();
expect(screen.getByText('volume:/usr/share/nginx/html (rw)')).toBeInTheDocument();
expect(screen.getByText('Available')).toBeInTheDocument();
});
it('renders Docker image API fields', () => {
render(() => (
<DockerImagesTable
@@ -5,8 +5,19 @@ import type { Resource } from '@/types/resource';
import { DockerPageSurface } from '../DockerPageSurface';
const mocks = vi.hoisted(() => ({
pathname: '/docker/overview',
useUnifiedResources: vi.fn(),
useWorkloadsState: vi.fn(),
DockerContainersTable: vi.fn(
(props: { resources: Resource[]; showToolbar?: boolean; emptyTitle: string }) => (
<div
data-testid="docker-containers-table"
data-resource-count={props.resources.length}
data-show-toolbar={String(props.showToolbar)}
>
{props.emptyTitle}
</div>
),
),
}));
vi.mock('@/hooks/useUnifiedResources', () => ({
@@ -14,24 +25,15 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
}));
vi.mock('@solidjs/router', () => ({
useLocation: () => ({ pathname: '/docker/overview' }),
useLocation: () => ({
get pathname() {
return mocks.pathname;
},
}),
}));
vi.mock('@/components/Workloads/useWorkloadsState', () => ({
useWorkloadsState: mocks.useWorkloadsState,
}));
vi.mock('@/components/Workloads/WorkloadsSurface', () => ({
WorkloadsSurface: (props: {
groupNodeDrawerMode?: string;
state?: { groupNodeDrawerMode?: () => string };
}) => (
<div
data-testid="docker-workloads-surface"
data-group-node-drawer-mode={props.groupNodeDrawerMode ?? ''}
data-state-group-node-drawer-mode={props.state?.groupNodeDrawerMode?.() ?? ''}
/>
),
vi.mock('../DockerContainersTable', () => ({
DockerContainersTable: mocks.DockerContainersTable,
}));
vi.mock('@/features/platformPage/sharedPlatformPage', async () => {
@@ -75,49 +77,19 @@ const makeDockerContainer = (overrides: Partial<Resource> = {}): Resource => ({
docker: {
runtime: 'docker',
hostname: 'docker-01',
image: 'nginx:latest',
} as NonNullable<Resource['docker']>,
...overrides,
});
const makeWorkloadsState = (props: { groupNodeDrawerMode?: 'inline' | 'disabled' }) =>
({
allGuests: () => [],
clearPinnedSummaryScope: vi.fn(),
containerRuntime: () => '',
containerRuntimeFilterConfig: () => undefined,
focusedSummaryWorkloadGroupId: () => null,
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
groupingMode: () => 'grouped',
handleBeforeAutoFocus: vi.fn(),
hostFilterConfig: () => undefined,
search: () => '',
selectedGuestId: () => null,
selectedNode: () => null,
setGroupingMode: vi.fn(),
setMetricDisplayMode: vi.fn(),
setSearch: vi.fn(),
setSortDirection: vi.fn(),
setSortKey: vi.fn(),
setStatusMode: vi.fn(),
setViewMode: vi.fn(),
setWorkloadMetricHistoryRange: vi.fn(),
statusMode: () => 'all',
surfaceConnected: () => false,
surfaceInitialDataReceived: () => false,
viewMode: () => 'app-container',
workloadMetricDisplayMode: () => 'bars',
workloadMetricHistoryRange: () => '1h',
workloadsFilterColumnVisibility: () => undefined,
}) as const;
beforeEach(() => {
mocks.pathname = '/docker/overview';
mocks.useUnifiedResources.mockReturnValue({
error: () => null,
loading: () => false,
refetch: vi.fn(),
resources: () => [makeDockerHost(), makeDockerContainer()],
});
mocks.useWorkloadsState.mockImplementation(makeWorkloadsState);
});
afterEach(() => {
@@ -126,7 +98,7 @@ afterEach(() => {
});
describe('DockerPageSurface', () => {
it('keeps host drawer ownership on the Docker hosts table instead of workload groups', () => {
it('uses native Docker inventory tables instead of the shared workload surface on overview', () => {
render(() => <DockerPageSurface />);
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
@@ -144,22 +116,28 @@ describe('DockerPageSurface', () => {
query: expect.stringContaining('docker-config'),
}),
);
expect(mocks.useWorkloadsState).toHaveBeenCalledWith(
expect.objectContaining({
compactGroupHeaders: true,
forcedPlatform: 'docker',
forcedViewMode: 'app-container',
groupNodeDrawerMode: 'disabled',
tableOnly: true,
}),
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'1',
);
expect(screen.getByTestId('docker-workloads-surface')).toHaveAttribute(
'data-group-node-drawer-mode',
'disabled',
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-show-toolbar',
'false',
);
expect(screen.getByTestId('docker-workloads-surface')).toHaveAttribute(
'data-state-group-node-drawer-mode',
'disabled',
});
it('renders the dedicated containers route through the Docker containers table', () => {
mocks.pathname = '/docker/containers';
render(() => <DockerPageSurface />);
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-resource-count',
'1',
);
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
'data-show-toolbar',
'undefined',
);
});
});
@@ -3,11 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
DOCKER_TAB_SPECS,
buildDockerContainerDefaultHiddenColumnIds,
buildDockerPageModel,
buildDockerWorkloadGroupLabelBadges,
filterDockerHosts,
filterDockerServices,
getDockerHostSystemBadge,
hasDockerSwarmEvidence,
} from '../dockerPageModel';
@@ -23,45 +19,6 @@ const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'
lastSeen: resource.lastSeen ?? 1_700_000_000_000,
});
const makeDockerHost = (overrides: Partial<Resource> = {}): Resource => ({
id: 'agent:docker-01',
name: 'docker-01',
displayName: 'Docker 01',
platformId: 'homelab',
platformType: 'docker',
sourceType: 'agent',
status: 'online',
type: 'agent',
lastSeen: 1_700_000_000_000,
docker: {
runtime: 'docker',
hostname: 'docker-01',
},
agent: {
hostname: 'docker-01',
},
...overrides,
});
const makeDockerService = (overrides: Partial<Resource> = {}): Resource => ({
id: 'docker-service:svc-01',
name: 'api',
displayName: 'api',
platformId: 'homelab',
platformType: 'docker',
sourceType: 'api',
status: 'online',
type: 'docker-service',
lastSeen: 1_700_000_000_000,
docker: {
hostname: 'docker-01',
hostSourceId: 'agent:docker-01',
image: 'ghcr.io/pulse/api:latest',
mode: 'replicated',
},
...overrides,
});
describe('dockerPageModel', () => {
it('buckets Docker hosts, containers, images, volumes, networks, nodes, tasks, secrets, configs, and Swarm services from canonical resources', () => {
const model = buildDockerPageModel([
@@ -140,50 +97,7 @@ describe('dockerPageModel', () => {
expect(model.resources).toEqual([]);
});
it('hides Docker container I/O columns by default when the snapshot has no I/O telemetry', () => {
expect(
buildDockerContainerDefaultHiddenColumnIds([
makeResource({ id: 'ctr-1', type: 'app-container' }),
]),
).toEqual(['disk', 'tags', 'backup', 'netIo', 'diskIo']);
});
it('keeps Docker container I/O columns default-visible once telemetry exists', () => {
expect(
buildDockerContainerDefaultHiddenColumnIds([
makeResource({
id: 'ctr-1',
type: 'app-container',
network: { rxBytes: 0, txBytes: 0 },
diskIO: { readRate: 0, writeRate: 0 },
}),
]),
).toEqual(['disk', 'tags', 'backup']);
});
it('decides Docker container network and disk I/O defaults independently', () => {
expect(
buildDockerContainerDefaultHiddenColumnIds([
makeResource({
id: 'ctr-1',
type: 'app-container',
network: { rxBytes: 128, txBytes: 64 },
}),
]),
).toEqual(['disk', 'tags', 'backup', 'diskIo']);
expect(
buildDockerContainerDefaultHiddenColumnIds([
makeResource({
id: 'ctr-2',
type: 'app-container',
diskIO: { readRate: 128, writeRate: 64 },
}),
]),
).toEqual(['disk', 'tags', 'backup', 'netIo']);
});
it('builds host-identity badges for Docker workload group rows', () => {
it('surfaces host-identity badges for Docker hosts', () => {
const lxcHost = makeResource({
id: 'proxmox-lxc-docker:pve:101',
type: 'agent',
@@ -199,12 +113,6 @@ describe('dockerPageModel', () => {
expect(getDockerHostSystemBadge(lxcHost)?.label).toBe('LXC');
expect(getDockerHostSystemBadge(genericDockerHost)).toBeUndefined();
const badges = buildDockerWorkloadGroupLabelBadges([lxcHost, genericDockerHost]);
expect(badges['app-container:frigate']?.label).toBe('LXC');
expect(badges['app-container:Frigate host']?.label).toBe('LXC');
expect(badges['app-container:proxmox-lxc-docker:pve:101']?.label).toBe('LXC');
expect(badges['app-container:plain-docker']).toBeUndefined();
});
it('surfaces the host OS family as the system badge for plain Docker hosts', () => {
@@ -266,69 +174,4 @@ describe('dockerPageModel', () => {
),
).toBe(true);
});
it('filters Docker hosts by runtime, status, search, and selected host scope', () => {
const hosts = [
makeDockerHost(),
makeDockerHost({
id: 'agent:podman-01',
name: 'podman-01',
displayName: 'Podman 01',
status: 'offline',
docker: {
runtime: 'podman',
hostname: 'podman-01',
},
agent: {
hostname: 'podman-01',
},
}),
];
expect(filterDockerHosts(hosts, { containerRuntime: 'podman' }).map((host) => host.id)).toEqual(
['agent:podman-01'],
);
expect(filterDockerHosts(hosts, { statusMode: 'stopped' }).map((host) => host.id)).toEqual([
'agent:podman-01',
]);
expect(filterDockerHosts(hosts, { searchTerm: 'docker 01' }).map((host) => host.id)).toEqual([
'agent:docker-01',
]);
expect(filterDockerHosts(hosts, { searchTerm: 'podman' }).map((host) => host.id)).toEqual([
'agent:podman-01',
]);
expect(
filterDockerHosts(hosts, { selectedHostScope: 'agent:podman-01' }).map((host) => host.id),
).toEqual(['agent:podman-01']);
});
it('filters Docker services with the shared page filters and hides them for Podman runtime scope', () => {
const services = [
makeDockerService(),
makeDockerService({
id: 'docker-service:svc-02',
name: 'worker',
status: 'degraded',
docker: {
hostname: 'docker-02',
hostSourceId: 'agent:docker-02',
image: 'ghcr.io/pulse/worker:latest',
mode: 'global',
},
}),
];
expect(filterDockerServices(services, { containerRuntime: 'podman' })).toEqual([]);
expect(
filterDockerServices(services, { statusMode: 'degraded' }).map((service) => service.id),
).toEqual(['docker-service:svc-02']);
expect(
filterDockerServices(services, { selectedHostScope: 'agent:docker-01' }).map(
(service) => service.id,
),
).toEqual(['docker-service:svc-01']);
expect(
filterDockerServices(services, { searchTerm: 'worker' }).map((service) => service.id),
).toEqual(['docker-service:svc-02']);
});
});
@@ -1,12 +1,9 @@
import { resolveResourcePlatformType } from '@/utils/sourcePlatforms';
import type { Resource, ResourceType } from '@/types/resource';
import type { WorkloadsStatusMode } from '@/components/Workloads/workloadsFilterModel';
import {
getInfrastructureSystemIdentityBadges,
type ResourceBadge,
} from '@/utils/resourceBadgePresentation';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
import { buildAppContainerDefaultHiddenColumnIds } from '@/features/platformPage/appContainerColumns';
const DOCKER_HOST_TYPES = new Set<ResourceType>(['agent', 'docker-host']);
const DOCKER_CONTAINER_TYPES = new Set<ResourceType>(['app-container']);
@@ -93,65 +90,13 @@ export type DockerPageModel = {
configs: Resource[];
};
export interface DockerPageFilters {
containerRuntime?: string | null;
searchTerm?: string | null;
selectedHostScope?: string | null;
statusMode?: WorkloadsStatusMode;
}
const RUNTIME_ONLY_SYSTEM_LABELS = new Set(['docker', 'docker / podman', 'podman']);
const addDockerGroupBadgeAlias = (
badges: Record<string, ResourceBadge>,
alias: string | undefined,
badge: ResourceBadge,
): void => {
const normalized = (alias || '').trim();
if (!normalized) return;
const key = `app-container:${normalized}`;
if (!badges[key]) {
badges[key] = badge;
}
const lowerKey = key.toLowerCase();
if (!badges[lowerKey]) {
badges[lowerKey] = badge;
}
};
export const getDockerHostSystemBadge = (host: Resource): ResourceBadge | undefined =>
getInfrastructureSystemIdentityBadges(host).find(
(badge) => !RUNTIME_ONLY_SYSTEM_LABELS.has(badge.label.trim().toLowerCase()),
);
export function buildDockerWorkloadGroupLabelBadges(
hosts: readonly Resource[],
): Record<string, ResourceBadge> {
const badges: Record<string, ResourceBadge> = {};
for (const host of hosts) {
const badge = getDockerHostSystemBadge(host);
if (!badge) continue;
addDockerGroupBadgeAlias(badges, host.name, badge);
addDockerGroupBadgeAlias(badges, host.displayName, badge);
addDockerGroupBadgeAlias(badges, host.id, badge);
addDockerGroupBadgeAlias(badges, host.agent?.hostname, badge);
addDockerGroupBadgeAlias(badges, host.docker?.hostname, badge);
addDockerGroupBadgeAlias(badges, host.identity?.hostname, badge);
addDockerGroupBadgeAlias(badges, host.canonicalIdentity?.displayName, badge);
addDockerGroupBadgeAlias(badges, host.canonicalIdentity?.hostname, badge);
addDockerGroupBadgeAlias(badges, host.canonicalIdentity?.primaryId, badge);
host.canonicalIdentity?.aliases?.forEach((alias) =>
addDockerGroupBadgeAlias(badges, alias, badge),
);
}
return badges;
}
export const buildDockerContainerDefaultHiddenColumnIds = buildAppContainerDefaultHiddenColumnIds;
export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
const dockerResources = resources.filter(
(resource) =>
@@ -196,168 +141,3 @@ export function buildDockerPageModel(resources: Resource[]): DockerPageModel {
configs,
};
}
const resourceSearchCandidates = (resource: Resource): Array<string | undefined> => [
resource.name,
resource.displayName,
resource.id,
resource.parentName,
resource.agent?.hostname,
resource.docker?.hostname,
resource.docker?.runtime,
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.os,
resource.docker?.kernelVersion,
resource.docker?.architecture,
resource.docker?.swarm?.clusterName,
resource.docker?.swarm?.nodeRole,
resource.docker?.imageId,
resource.docker?.volumeName,
resource.docker?.networkId,
resource.docker?.driver,
resource.docker?.mountpoint,
resource.docker?.scope,
resource.docker?.taskId,
resource.docker?.secretId,
resource.docker?.secretName,
resource.docker?.configId,
resource.docker?.configName,
resource.docker?.templatingDriver,
resource.docker?.serviceName,
resource.docker?.nodeId,
resource.docker?.nodeName,
resource.docker?.nodeRole,
resource.docker?.availability,
resource.docker?.managerReachability,
resource.docker?.engineVersion,
resource.docker?.address,
resource.docker?.currentState,
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),
...(resource.docker?.subnets?.map((subnet) => subnet.subnet) ?? []),
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
...(resource.tags ?? []),
];
const matchesSearch = (resource: Resource, searchTerm: string): boolean => {
const needle = searchTerm.trim().toLowerCase();
if (!needle) return true;
return resourceSearchCandidates(resource)
.filter((value): value is string => typeof value === 'string')
.some((value) => value.toLowerCase().includes(needle));
};
const matchesStatusMode = (
resource: Resource,
statusMode: WorkloadsStatusMode | undefined,
): boolean => {
if (!statusMode || statusMode === 'all') return true;
const normalizedStatus = (resource.status || '').trim().toLowerCase();
if (statusMode === 'running') {
return normalizedStatus === 'running' || normalizedStatus === 'online';
}
if (statusMode === 'degraded') {
return (
DEGRADED_HEALTH_STATUSES.has(normalizedStatus) ||
(normalizedStatus !== 'running' &&
normalizedStatus !== 'online' &&
!OFFLINE_HEALTH_STATUSES.has(normalizedStatus))
);
}
return OFFLINE_HEALTH_STATUSES.has(normalizedStatus);
};
const matchesContainerRuntime = (
resource: Resource,
containerRuntime: string | null | undefined,
): boolean => {
const normalizedRuntime = (containerRuntime || '').trim().toLowerCase();
if (!normalizedRuntime) return true;
return (resource.docker?.runtime || '').trim().toLowerCase() === normalizedRuntime;
};
const matchesHostScope = (
resource: Resource,
selectedHostScope: string | null | undefined,
): boolean => {
const normalizedScope = (selectedHostScope || '').trim().toLowerCase();
if (!normalizedScope) return true;
const candidates = [
resource.id,
resource.name,
resource.displayName,
resource.docker?.hostSourceId,
resource.docker?.hostname,
resource.agent?.hostname,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
]
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
return candidates.includes(normalizedScope);
};
export function filterDockerHosts(
resources: readonly Resource[],
filters: DockerPageFilters,
): Resource[] {
return resources.filter(
(resource) =>
matchesSearch(resource, filters.searchTerm || '') &&
matchesStatusMode(resource, filters.statusMode) &&
matchesContainerRuntime(resource, filters.containerRuntime) &&
matchesHostScope(resource, filters.selectedHostScope),
);
}
const serviceSearchCandidates = (resource: Resource): Array<string | undefined> => [
...resourceSearchCandidates(resource),
resource.docker?.image,
resource.docker?.mode,
];
export function filterDockerServices(
resources: readonly Resource[],
filters: DockerPageFilters,
): Resource[] {
const normalizedRuntime = (filters.containerRuntime || '').trim().toLowerCase();
if (normalizedRuntime === 'podman') {
return [];
}
return resources.filter(
(resource) =>
serviceSearchCandidates(resource)
.filter((value): value is string => typeof value === 'string')
.some((value) => {
const needle = (filters.searchTerm || '').trim().toLowerCase();
return needle.length === 0 ? true : value.toLowerCase().includes(needle);
}) &&
matchesStatusMode(resource, filters.statusMode) &&
matchesHostScope(resource, filters.selectedHostScope),
);
}
export function filterDockerInventoryResources(
resources: readonly Resource[],
filters: DockerPageFilters,
): Resource[] {
return resources.filter(
(resource) =>
matchesSearch(resource, filters.searchTerm || '') &&
matchesStatusMode(resource, filters.statusMode) &&
matchesContainerRuntime(resource, filters.containerRuntime) &&
matchesHostScope(resource, filters.selectedHostScope),
);
}
@@ -3,6 +3,7 @@ import agentsMachinesTableSource from '@/features/agents/AgentsMachinesTable.tsx
import agentsPageModelSource from '@/features/agents/agentsPageModel.ts?raw';
import agentsPageSurfaceSource from '@/features/agents/AgentsPageSurface.tsx?raw';
import dockerConfigsTableSource from '@/features/docker/DockerConfigsTable.tsx?raw';
import dockerContainersTableSource from '@/features/docker/DockerContainersTable.tsx?raw';
import dockerHostsTableSource from '@/features/docker/DockerHostsTable.tsx?raw';
import dockerImagesTableSource from '@/features/docker/DockerImagesTable.tsx?raw';
import dockerNetworksTableSource from '@/features/docker/DockerNetworksTable.tsx?raw';
@@ -51,6 +52,7 @@ import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw
const platformTableSources = [
agentsMachinesTableSource,
proxmoxNodesTableSource,
dockerContainersTableSource,
dockerHostsTableSource,
dockerImagesTableSource,
dockerVolumesTableSource,
@@ -87,6 +89,7 @@ const platformTableSources = [
const platformToolbarTableSources = [
agentsMachinesTableSource,
dockerContainersTableSource,
dockerHostsTableSource,
dockerImagesTableSource,
dockerVolumesTableSource,
@@ -221,10 +224,11 @@ describe('platform overview layout guardrails', () => {
expect(proxmoxPageSurfaceSource).toContain('<ProxmoxNodesTable');
expect(proxmoxPageSurfaceSource).toContain('<WorkloadsSurface');
expect(dockerPageSurfaceSource).toContain('<DockerHostsTable');
expect(dockerPageSurfaceSource).toContain('<DockerContainersTable');
expect(dockerPageSurfaceSource).toContain('<DockerImagesTable');
expect(dockerPageSurfaceSource).toContain('<DockerVolumesTable');
expect(dockerPageSurfaceSource).toContain('<DockerNetworksTable');
expect(dockerPageSurfaceSource).toContain('<WorkloadsSurface');
expect(dockerPageSurfaceSource).not.toContain('<WorkloadsSurface');
expect(dockerPageSurfaceSource).toContain('<DockerSwarmNodesTable');
expect(dockerPageSurfaceSource).toContain('<DockerServicesTable');
expect(dockerPageSurfaceSource).toContain('<DockerTasksTable');
@@ -182,6 +182,9 @@ const mapResourceStatusToTriad = (
return 'unknown';
};
const platformSearchNumber = (value: number | undefined): string | undefined =>
typeof value === 'number' ? String(value) : undefined;
const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
if (!search) return true;
const needle = search.trim().toLowerCase();
@@ -203,8 +206,16 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.docker?.runtime,
resource.docker?.runtimeVersion,
resource.docker?.dockerVersion,
resource.docker?.containerId,
resource.docker?.image,
resource.docker?.imageId,
resource.docker?.containerState,
resource.docker?.health,
platformSearchNumber(resource.docker?.restartCount),
platformSearchNumber(resource.docker?.exitCode),
resource.docker?.updateStatus?.error,
resource.docker?.updateStatus?.currentDigest,
resource.docker?.updateStatus?.latestDigest,
resource.docker?.volumeName,
resource.docker?.networkId,
resource.docker?.driver,
@@ -247,6 +258,24 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => {
resource.vmware?.networkType,
resource.vmware?.networkHostNames?.join(' '),
resource.vmware?.networkVmNames?.join(' '),
...(resource.docker?.ports?.flatMap((port) => [
port.ip,
port.protocol,
platformSearchNumber(port.privatePort),
platformSearchNumber(port.publicPort),
]) ?? []),
...(resource.docker?.networks?.flatMap((network) => [
network.name,
network.ipv4,
network.ipv6,
]) ?? []),
...(resource.docker?.mounts?.flatMap((mount) => [
mount.type,
mount.source,
mount.destination,
mount.mode,
mount.rw === false ? 'read-only' : mount.rw === true ? 'read-write' : undefined,
]) ?? []),
...(resource.docker?.repoTags ?? []),
...(resource.docker?.repoDigests ?? []),
...(resource.kubernetes?.externalIps ?? []),
+15 -4
View File
@@ -566,10 +566,11 @@ export interface ResourceProxmoxMeta {
kernelVersion?: string;
}
// Docker runtime and Swarm service projection emitted by the canonical
// adapter. Host resources use the runtime fields on the Docker platform
// hosts table; `docker-service` resources use the service fields on the
// Swarm services table.
// Docker runtime, container, and Swarm service projection emitted by the
// canonical adapter. Host resources use the runtime fields on the Docker
// platform hosts table; `app-container` resources use the container fields
// on the Docker containers table; `docker-service` resources use the service
// fields on the Swarm services table.
export interface ResourceDockerMeta {
serviceId?: string;
serviceName?: string;
@@ -610,6 +611,9 @@ export interface ResourceDockerMeta {
sharedSizeBytes?: number;
imageContainers?: number;
containerState?: string;
health?: string;
restartCount?: number;
exitCode?: number;
ports?: Array<{
privatePort?: number;
publicPort?: number;
@@ -628,6 +632,13 @@ export interface ResourceDockerMeta {
ipv4?: string;
ipv6?: string;
}>;
updateStatus?: {
updateAvailable?: boolean;
currentDigest?: string;
latestDigest?: string;
lastChecked?: string;
error?: string;
};
mode?: string;
desiredTasks?: number;
runningTasks?: number;
+7
View File
@@ -1904,7 +1904,9 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo
docker := &DockerData{
HostSourceID: host.ID,
ContainerID: ct.ID,
Hostname: host.Hostname,
Image: ct.Image,
ImageID: ct.ImageDigest,
UptimeSeconds: ct.UptimeSeconds,
ContainerState: ct.State,
Health: ct.Health,
@@ -1912,6 +1914,11 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo
ExitCode: ct.ExitCode,
Labels: cloneLabelMap(ct.Labels),
Runtime: runtime,
RuntimeVersion: host.RuntimeVersion,
DockerVersion: host.DockerVersion,
}
if !ct.CreatedAt.IsZero() {
docker.CreatedAt = ct.CreatedAt.UTC().Format(time.RFC3339)
}
if len(ct.Ports) > 0 {
docker.Ports = make([]DockerPortMeta, len(ct.Ports))
+14 -3
View File
@@ -143,14 +143,16 @@ func TestResourceFromDockerContainerIncludesContainerID(t *testing.T) {
Name: "web",
State: "running",
Image: "ghcr.io/example/web:1.0.0",
ImageDigest: "sha256:current",
UptimeSeconds: 1234,
CPUPercent: 12.5,
}
host := models.DockerHost{
ID: "docker-1",
Hostname: "docker-1",
Runtime: "podman",
ID: "docker-1",
Hostname: "docker-1",
Runtime: "podman",
RuntimeVersion: "5.2.0",
}
resource, _ := resourceFromDockerContainer(container, host)
if resource.Docker == nil {
@@ -162,6 +164,15 @@ func TestResourceFromDockerContainerIncludesContainerID(t *testing.T) {
if got, want := resource.Docker.Runtime, host.Runtime; got != want {
t.Fatalf("runtime = %q, want %q", got, want)
}
if got, want := resource.Docker.Hostname, host.Hostname; got != want {
t.Fatalf("hostname = %q, want %q", got, want)
}
if got, want := resource.Docker.RuntimeVersion, host.RuntimeVersion; got != want {
t.Fatalf("runtimeVersion = %q, want %q", got, want)
}
if got, want := resource.Docker.ImageID, container.ImageDigest; got != want {
t.Fatalf("imageId = %q, want %q", got, want)
}
}
func TestResourceFromHostProjectsAgentHostProfile(t *testing.T) {