mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Add native Docker containers table
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-1
@@ -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 ?? []),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user