mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
docker(tables): restore v5 information to the Docker page tables
The v6 Docker page regressed against its v5 ancestor (same disease as the physical disks table, #1471): container CPU/memory flowed all the way into the unified payload but no column rendered them, so a container pegged at 75% CPU looked identical to an idle one. Labels (compose project/service, traefik rules) and the Swarm stack name shipped in the payload but were unrendered and unsearchable. - Add always-visible CPU and Memory metric-bar columns to the containers table (ResponsiveMetricCell / StackedMemoryBar, same primitives as the hosts table above it). - Flag crash-loopers: restart counts above 5 render red, matching the v5 threshold. - Demote the Runtime column to mixed-runtime fleets only; it repeated host-level data on every container row (the #1471 Source-badge pattern) and the Host column sits right next to it. - Add a Stack column to the Swarm services table, derived from the serialized stack field's canonical com.docker.stack.namespace label. - Make container/service labels and stack names searchable via the Docker search haystack (v5 searched labels). - Render volume Created as relative time with the absolute timestamp in the tooltip instead of a raw ISO 8601 string.
This commit is contained in:
@@ -2,6 +2,8 @@ import { For, Show, createMemo, type Component } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import type { FilterDef } from '@/components/shared/FilterBar';
|
||||
import { UpdateButton } from '@/components/shared/ContainerUpdateBadge';
|
||||
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
|
||||
import { TableCard } from '@/components/shared/TableCard';
|
||||
import { TableCardHeader } from '@/components/shared/TableCardHeader';
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import {
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PLATFORM_TABLE_BODY_CLASS,
|
||||
@@ -34,7 +37,6 @@ import {
|
||||
DockerResourceNameCell,
|
||||
dockerHostName,
|
||||
dockerJoinValues,
|
||||
dockerNumberValue,
|
||||
dockerTextValue,
|
||||
type DockerNativeTableProps,
|
||||
} from './DockerNativeTableShared';
|
||||
@@ -88,6 +90,25 @@ const runtimeSummary = (resource: Resource): string => {
|
||||
const containerState = (resource: Resource): string =>
|
||||
dockerTextValue(resource.docker?.containerState || resource.status);
|
||||
|
||||
const isContainerRunning = (resource: Resource): boolean =>
|
||||
(asTrimmedString(resource.docker?.containerState || resource.status) ?? '').toLowerCase() ===
|
||||
'running';
|
||||
|
||||
const finiteMetric = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const metricFallback = () => (
|
||||
<div class="flex justify-center">
|
||||
<span class="text-xs text-muted" aria-hidden="true">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// v5 flagged crash-loopers in the restarts column; a container that restarted
|
||||
// more than this many times needs an operator's eye even while "running".
|
||||
const RESTART_ATTENTION_THRESHOLD = 5;
|
||||
|
||||
const updateStatusLabel = (resource: Resource): string => {
|
||||
const update = resource.docker?.updateStatus;
|
||||
if (!update) return '—';
|
||||
@@ -210,6 +231,17 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
setHostFilter('');
|
||||
};
|
||||
const sortedRows = createMemo(() => [...scopedRows()].sort(compareDockerContainers));
|
||||
// Runtime is host-level data repeated on every container row; it only
|
||||
// informs when the fleet actually mixes runtimes (docker vs podman).
|
||||
const showRuntimeColumn = createMemo(() => {
|
||||
const kinds = new Set<string>();
|
||||
for (const resource of props.resources) {
|
||||
const kind = (asTrimmedString(resource.docker?.runtime) ?? '').toLowerCase();
|
||||
if (kind) kinds.add(kind);
|
||||
}
|
||||
return kinds.size > 1;
|
||||
});
|
||||
const drawerColSpan = createMemo(() => (showRuntimeColumn() ? 13 : 12));
|
||||
const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-container-drawer' });
|
||||
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources);
|
||||
|
||||
@@ -255,7 +287,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<TableCardHeader title={props.title ?? 'Containers'} />
|
||||
<Table class="min-w-[790px] table-fixed text-xs md:min-w-[1680px]">
|
||||
<Table class="min-w-[1010px] table-fixed text-xs md:min-w-[1900px]">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-[220px]`}>
|
||||
@@ -266,11 +298,13 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
>
|
||||
Host
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[130px]`}
|
||||
>
|
||||
Runtime
|
||||
</TableHead>
|
||||
<Show when={showRuntimeColumn()}>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[130px]`}
|
||||
>
|
||||
Runtime
|
||||
</TableHead>
|
||||
</Show>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[260px]`}>
|
||||
Image
|
||||
</TableHead>
|
||||
@@ -282,6 +316,17 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
>
|
||||
Health
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[110px] md:w-[140px]`}
|
||||
>
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[110px] md:w-[140px]`}
|
||||
>
|
||||
<span class="md:hidden">Mem</span>
|
||||
<span class="hidden md:inline">Memory</span>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('numeric-value')} w-[110px]`}
|
||||
>
|
||||
@@ -316,6 +361,16 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
const health = () => dockerTextValue(resource.docker?.health);
|
||||
const runtime = () => runtimeSummary(resource);
|
||||
const host = () => dockerHostName(resource);
|
||||
const running = () => isContainerRunning(resource);
|
||||
const metricsKey = () => buildMetricKeyForUnifiedResource(resource);
|
||||
const cpuPercent = () => finiteMetric(resource.cpu?.current);
|
||||
const memoryUsed = () => finiteMetric(resource.memory?.used) ?? 0;
|
||||
const memoryTotal = () => finiteMetric(resource.memory?.total) ?? 0;
|
||||
const memoryPercentOnly = () =>
|
||||
memoryTotal() > 0 ? undefined : finiteMetric(resource.memory?.current);
|
||||
const hasMemoryMetric = () =>
|
||||
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
|
||||
const restartCount = () => resource.docker?.restartCount ?? 0;
|
||||
const ports = () => dockerContainerPortsSummary(resource);
|
||||
const networks = () => networksSummary(resource);
|
||||
const mounts = () => mountsSummary(resource);
|
||||
@@ -344,13 +399,15 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
{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>
|
||||
<Show when={showRuntimeColumn()}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="block max-w-full truncate" title={runtime()}>
|
||||
{runtime()}
|
||||
</span>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
@@ -368,10 +425,49 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
>
|
||||
{health()}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[110px] md:w-auto`}
|
||||
>
|
||||
<ResponsiveMetricCell
|
||||
class="w-full"
|
||||
value={cpuPercent() ?? 0}
|
||||
type="cpu"
|
||||
resourceId={metricsKey()}
|
||||
isRunning={running() && cpuPercent() !== undefined}
|
||||
showMobile={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[110px] md:w-auto`}
|
||||
>
|
||||
<Show
|
||||
when={running() && hasMemoryMetric()}
|
||||
fallback={metricFallback()}
|
||||
>
|
||||
<StackedMemoryBar
|
||||
used={memoryUsed()}
|
||||
total={memoryTotal()}
|
||||
percentOnly={memoryPercentOnly()}
|
||||
/>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{dockerNumberValue(resource.docker?.restartCount)}
|
||||
<Show
|
||||
when={typeof resource.docker?.restartCount === 'number'}
|
||||
fallback={<span>—</span>}
|
||||
>
|
||||
<span
|
||||
class={`tabular-nums ${
|
||||
restartCount() > RESTART_ATTENTION_THRESHOLD
|
||||
? 'font-medium text-red-600 dark:text-red-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{resource.docker?.restartCount}
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
@@ -423,7 +519,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
||||
resource={resource}
|
||||
open={isExpanded()}
|
||||
detailRowId={detailRowId()}
|
||||
colSpan={11}
|
||||
colSpan={drawerColSpan()}
|
||||
resolveResourceLabel={resolveResourceLabel}
|
||||
onClose={() => drawer.close(resource)}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
compareDockerServices,
|
||||
dockerServiceStack,
|
||||
filterDockerResources,
|
||||
mapDockerServiceStatus,
|
||||
type DockerResourceStatusFilter,
|
||||
@@ -154,11 +155,16 @@ export const DockerServicesTable: Component<{
|
||||
Host get middle slices for rollout state, port lists, and
|
||||
hostnames. Mobile widths are unchanged.
|
||||
*/}
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
|
||||
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[16%]`}>
|
||||
Service
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[22%]`}
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
|
||||
>
|
||||
Stack
|
||||
</TableHead>
|
||||
<TableHead
|
||||
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[19%]`}
|
||||
>
|
||||
Image
|
||||
</TableHead>
|
||||
@@ -196,6 +202,7 @@ export const DockerServicesTable: Component<{
|
||||
<For each={sortedRows()}>
|
||||
{(service) => {
|
||||
const name = () => asTrimmedString(service.name) || service.id;
|
||||
const stack = () => dockerServiceStack(service) || '—';
|
||||
const image = () => asTrimmedString(service.docker?.image) || '—';
|
||||
const mode = () => asTrimmedString(service.docker?.mode) || '—';
|
||||
const host = () => asTrimmedString(service.docker?.hostname) || '—';
|
||||
@@ -216,6 +223,13 @@ export const DockerServicesTable: Component<{
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
<span class="truncate inline-block max-w-[8rem]" title={stack()}>
|
||||
{stack()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,23 @@ import {
|
||||
type DockerNativeTableProps,
|
||||
} from './DockerNativeTableShared';
|
||||
import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
const parseCreatedAt = (value: string | undefined): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const createdRelative = (value: string | undefined): string => {
|
||||
const parsed = parseCreatedAt(value);
|
||||
return parsed === null ? dockerTextValue(value) : formatRelativeTime(parsed);
|
||||
};
|
||||
|
||||
const createdAbsolute = (value: string | undefined): string => {
|
||||
const parsed = parseCreatedAt(value);
|
||||
return parsed === null ? dockerTextValue(value) : new Date(parsed).toLocaleString();
|
||||
};
|
||||
|
||||
export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) => {
|
||||
const tableState = createPlatformTableFilterState({
|
||||
@@ -164,9 +181,9 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[12rem] truncate"
|
||||
title={dockerTextValue(resource.docker?.createdAt)}
|
||||
title={createdAbsolute(resource.docker?.createdAt)}
|
||||
>
|
||||
{dockerTextValue(resource.docker?.createdAt)}
|
||||
{createdRelative(resource.docker?.createdAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
|
||||
@@ -36,6 +36,29 @@ vi.mock('@/stores/systemSettings', () => ({
|
||||
shouldHideDockerUpdateActions: () => false,
|
||||
}));
|
||||
|
||||
// jsdom has no ResizeObserver; stub the shared metric cells like the
|
||||
// DockerHostsTable tests do.
|
||||
vi.mock('@/components/shared/responsive', () => ({
|
||||
ResponsiveMetricCell: (props: { type: string; isRunning?: boolean; resourceId?: string }) => (
|
||||
<div
|
||||
data-testid={`responsive-${props.type}-metric`}
|
||||
data-resource-id={props.resourceId ?? ''}
|
||||
data-running={String(props.isRunning)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
|
||||
StackedMemoryBar: (props: { used: number; total: number; percentOnly?: number }) => (
|
||||
<div
|
||||
data-testid="stacked-memory-bar"
|
||||
data-used={String(props.used)}
|
||||
data-total={String(props.total)}
|
||||
data-percent-only={String(props.percentOnly ?? '')}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeResource = ({
|
||||
id,
|
||||
type,
|
||||
@@ -78,6 +101,8 @@ describe('Docker native tables', () => {
|
||||
type: 'app-container',
|
||||
name: 'edge-web',
|
||||
status: 'running',
|
||||
cpu: { current: 42 },
|
||||
memory: { current: 50, used: 512 * 1024 * 1024, total: 1024 * 1024 * 1024 },
|
||||
docker: {
|
||||
hostname: 'edge-01',
|
||||
runtime: 'docker',
|
||||
@@ -105,6 +130,21 @@ describe('Docker native tables', () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'container-2',
|
||||
type: 'app-container',
|
||||
name: 'edge-cache',
|
||||
status: 'running',
|
||||
docker: {
|
||||
hostname: 'edge-02',
|
||||
runtime: 'podman',
|
||||
runtimeVersion: '5.2.1',
|
||||
image: 'redis:7.4',
|
||||
containerState: 'running',
|
||||
health: 'healthy',
|
||||
restartCount: 7,
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
@@ -115,18 +155,74 @@ describe('Docker native tables', () => {
|
||||
|
||||
expect(screen.getByText('Container')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runtime')).toBeInTheDocument();
|
||||
expect(screen.getByText('CPU')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory')).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('podman 5.2.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('nginx:latest')).toBeInTheDocument();
|
||||
expect(screen.getByText('healthy')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('healthy').length).toBe(2);
|
||||
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();
|
||||
|
||||
// A crash-looper (restarts above the v5 attention threshold) is flagged.
|
||||
expect(screen.getByText('7')).toHaveClass('text-red-600');
|
||||
expect(screen.getByText('2')).not.toHaveClass('text-red-600');
|
||||
|
||||
// Both rows get a CPU cell; only the row with memory data gets a bar.
|
||||
expect(screen.getAllByTestId('responsive-cpu-metric').length).toBe(2);
|
||||
expect(screen.getByTestId('stacked-memory-bar')).toHaveAttribute(
|
||||
'data-used',
|
||||
String(512 * 1024 * 1024),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the Runtime column when every container reports the same runtime', () => {
|
||||
renderInRouter(() => (
|
||||
<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',
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'container-2',
|
||||
type: 'app-container',
|
||||
name: 'edge-cache',
|
||||
status: 'running',
|
||||
docker: {
|
||||
hostname: 'edge-02',
|
||||
runtime: 'docker',
|
||||
runtimeVersion: '27.5.1',
|
||||
image: 'redis:7.4',
|
||||
containerState: 'running',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByText('Runtime')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('docker 27.5.1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actionable Docker container updates with native agent and container IDs', async () => {
|
||||
@@ -279,7 +375,10 @@ describe('Docker native tables', () => {
|
||||
expect(screen.getByText('app-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('local')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('2026-05-24T13:00:00Z')).toBeInTheDocument();
|
||||
// Created renders as a relative time with the absolute timestamp in the
|
||||
// tooltip, not as a raw ISO string.
|
||||
expect(screen.queryByText('2026-05-24T13:00:00Z')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/ago$/)).toBeInTheDocument();
|
||||
expect(screen.getByText('/var/lib/docker/volumes/app-data/_data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -611,6 +710,7 @@ describe('Docker native tables', () => {
|
||||
mode: 'replicated',
|
||||
desiredTasks: 4,
|
||||
runningTasks: 2,
|
||||
labels: { 'com.docker.stack.namespace': 'shop' },
|
||||
endpointPorts: [
|
||||
{ protocol: 'tcp', targetPort: 8080, publishedPort: 18080, publishMode: 'ingress' },
|
||||
],
|
||||
@@ -629,6 +729,8 @@ describe('Docker native tables', () => {
|
||||
));
|
||||
|
||||
expect(screen.getByText('Update')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stack')).toBeInTheDocument();
|
||||
expect(screen.getByText('shop')).toBeInTheDocument();
|
||||
expect(screen.getByText('checkout-api')).toBeInTheDocument();
|
||||
expect(screen.getByText('registry.example.com/checkout-api:2026.05')).toBeInTheDocument();
|
||||
expect(screen.getByText('replicated')).toBeInTheDocument();
|
||||
|
||||
@@ -790,6 +790,43 @@ describe('dockerPageModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches container labels and Swarm stack names', () => {
|
||||
const labelled: Resource[] = [
|
||||
...rows,
|
||||
makeResource({
|
||||
id: 'compose-db',
|
||||
type: 'app-container',
|
||||
status: 'online',
|
||||
docker: {
|
||||
containerState: 'running',
|
||||
labels: {
|
||||
'com.docker.compose.project': 'orion',
|
||||
'com.docker.compose.service': 'database',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'svc-stacked',
|
||||
type: 'docker-service',
|
||||
status: 'online',
|
||||
docker: {
|
||||
serviceName: 'shop-api',
|
||||
labels: { 'com.docker.stack.namespace': 'shopstack' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(filterDockerResources(labelled, 'orion', 'all').map((r) => r.id)).toEqual([
|
||||
'compose-db',
|
||||
]);
|
||||
expect(filterDockerResources(labelled, 'database', 'all').map((r) => r.id)).toEqual([
|
||||
'compose-db',
|
||||
]);
|
||||
expect(filterDockerResources(labelled, 'shopstack', 'all').map((r) => r.id)).toEqual([
|
||||
'svc-stacked',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still combines status and search', () => {
|
||||
expect(filterDockerResources(rows, 'payments-worker', 'degraded').map((r) => r.id)).toEqual([
|
||||
'svc-payments',
|
||||
|
||||
@@ -377,6 +377,20 @@ export function buildDockerNetworkAttachmentRows(
|
||||
.sort((left, right) => compareDockerContainers(left.resource, right.resource));
|
||||
}
|
||||
|
||||
// The API serializes a `stack` field for Swarm services, but the frontend
|
||||
// ResourceDockerMeta type does not declare it yet; until it does, fall back
|
||||
// to the canonical Swarm stack label every stack deploy stamps on the
|
||||
// service.
|
||||
export const dockerServiceStack = (resource: Resource): string => {
|
||||
const docker = resource.docker as
|
||||
| (NonNullable<Resource['docker']> & { stack?: string })
|
||||
| undefined;
|
||||
return (
|
||||
asTrimmedString(docker?.stack) ||
|
||||
asTrimmedString(docker?.labels?.['com.docker.stack.namespace'])
|
||||
);
|
||||
};
|
||||
|
||||
// Builds the lowercase search haystack a Docker page table consults when
|
||||
// filtering rows. The shared platformPage helper carries only generic Resource
|
||||
// fields; docker.* lookups live here so the cross-platform helper does not
|
||||
@@ -453,6 +467,11 @@ export function dockerResourceSearchHaystack(resource: Resource): string {
|
||||
]) ?? []),
|
||||
...(docker?.repoTags ?? []),
|
||||
...(docker?.repoDigests ?? []),
|
||||
// Labels carry the operator-meaningful container organizers (compose
|
||||
// project/service, Swarm stack, traefik rules); v5 searched them and the
|
||||
// unified payload still ships them.
|
||||
...(docker?.labels ? Object.entries(docker.labels).flatMap(([key, value]) => [key, value]) : []),
|
||||
dockerServiceStack(resource),
|
||||
...(resource.tags ?? []),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
|
||||
Reference in New Issue
Block a user