mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Scope Docker drawer handoff by host
This commit is contained in:
parent
6ca91d47e6
commit
21c79c898c
16 changed files with 266 additions and 13 deletions
|
|
@ -839,6 +839,11 @@ not a replacement status card, CTA band, or page-local nested card.
|
|||
remains the canonical detailed Docker / Podman container lens. The overview
|
||||
table should not add peer rows, badges, or child rows for Docker containers;
|
||||
those signals compete with VM/LXC state and belong one click down.
|
||||
Drawer-to-runtime navigation is still part of the shared platform-table
|
||||
affordance contract: when the LXC drawer exposes an `Open Docker` action for
|
||||
nested containers, that action must use the Docker host facet route state so
|
||||
the target Docker Overview opens scoped to the same runtime instead of a
|
||||
broad, visually unrelated container list.
|
||||
Primary navigation uses that same membership model: the Docker route is
|
||||
the container-runtime lens and may be labelled `Containers` in the shell,
|
||||
while shared source badges, filters, and runtime management copy continue
|
||||
|
|
|
|||
|
|
@ -167,6 +167,11 @@ regression protection.
|
|||
canonical workload IDs and ambiguity-safe runtime-host matching; row and
|
||||
drawer components may render the summary they are given, but they must not
|
||||
scan hidden workloads per row or guess ownership from ambiguous VMIDs.
|
||||
The same bounded derivation owns Docker host-facet handoff data for nested
|
||||
runtime links. `useWorkloads.ts` must carry the Docker runtime hostname onto
|
||||
`WorkloadGuest` so `nestedWorkloadContext.ts` can build a route-scoped Docker
|
||||
link without scanning unified resources again inside drawers or Docker table
|
||||
renderers.
|
||||
The same `useWorkloads.ts` mapping owns canonical-field fallback for
|
||||
per-row scalars the workload table reads. `WorkloadGuest.uptime` must
|
||||
fall back to the canonical `Resource.Uptime` field after the
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ Workloads table demotes Docker-in-LXC `app-container` rows out of peer
|
|||
membership, the backup surface must still receive the page model's Proxmox
|
||||
guest inventory for VM/LXC coverage and must not reinterpret Docker runtime
|
||||
membership as backup or recovery ownership.
|
||||
Docker-in-LXC drawer links may add Docker runtime host route state through the
|
||||
shared `resourceLinks.ts` module, but that query state is Docker-lens scope
|
||||
only. Storage and recovery route builders/parsers must not reuse that Docker
|
||||
host facet or treat it as backup-source, datastore, node, or recovery ownership.
|
||||
The platform page embedding point may pass read-only Proxmox guest inventory
|
||||
into that backup surface solely for workload identity correlation; the backup
|
||||
surface must still source restore evidence from the PVE/PBS backup APIs rather
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ workload, service, storage, configuration, policy, and event object rows belong
|
|||
in their workflow tabs. If a future Overview repeats a detailed table, the
|
||||
owning workflow must be retired or the Overview content must be reduced to
|
||||
aggregate signal.
|
||||
Cross-platform handoffs into the Docker / Podman Overview must preserve runtime
|
||||
scope when the source surface knows it. A Proxmox LXC drawer that links to the
|
||||
Docker runtime lens must carry the unambiguous Docker host facet as route state,
|
||||
and Docker Overview must apply that host scope to both the runtime host summary
|
||||
and the primary container table so the handoff lands on the same runtime the
|
||||
operator just inspected.
|
||||
Platform native tables are unified-resource consumers even when their visual
|
||||
table frame is frontend-primitives-owned. Docker / Podman, Kubernetes, Proxmox,
|
||||
Standalone, TrueNAS, and vSphere platform tables own source-specific row fields,
|
||||
|
|
@ -2290,7 +2296,12 @@ it must not promote those `app-container` rows into the default Proxmox peer
|
|||
workload table or add always-visible child rows that compete with VM/LXC scan
|
||||
flow. That nested context must be read-only and keyed by the same canonical
|
||||
Proxmox guest identity and Docker host source identity used by the resource
|
||||
model; ambiguous runtime-host matches must be omitted rather than guessed.
|
||||
model; ambiguous runtime-host matches must be omitted rather than guessed. When
|
||||
that drawer links into the Docker / Podman runtime lens, it must carry the
|
||||
unambiguous Docker host facet in the route (`host=<runtime hostname>`) and the
|
||||
Docker overview must apply that scope to the host and container tables; if the
|
||||
runtime host label is missing or ambiguous, the handoff falls back to the
|
||||
unfiltered Docker overview.
|
||||
The same facet bundle now also returns grouped recent-change counts by
|
||||
canonical change kind, so the detail drawer can surface the distribution of
|
||||
state transitions, restarts, config updates, and anomalies without
|
||||
|
|
|
|||
|
|
@ -581,7 +581,7 @@ describe('GuestDrawer', () => {
|
|||
label: 'Docker',
|
||||
title: 'Nested Docker',
|
||||
count: 2,
|
||||
href: '/docker',
|
||||
href: '/docker/overview?host=media-lxc.mist-stork.ts.net',
|
||||
items: [
|
||||
{ id: 'container-1', name: 'frigate', runtimeLabel: 'Docker', status: 'running' },
|
||||
{ id: 'container-2', name: 'mosquitto', runtimeLabel: 'Docker', status: 'exited' },
|
||||
|
|
@ -599,7 +599,7 @@ describe('GuestDrawer', () => {
|
|||
expect(within(card).getByText('mosquitto')).toBeInTheDocument();
|
||||
expect(
|
||||
within(card).getByRole('link', { name: 'Open Docker page for media-lxc' }),
|
||||
).toHaveAttribute('href', '/docker');
|
||||
).toHaveAttribute('href', '/docker/overview?host=media-lxc.mist-stork.ts.net');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ describe('nested workload context', () => {
|
|||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
dockerHostName: 'media-lxc.mist-stork.ts.net',
|
||||
image: 'ghcr.io/blakeblackshear/frigate:stable',
|
||||
});
|
||||
|
||||
|
|
@ -62,11 +63,55 @@ describe('nested workload context', () => {
|
|||
expect(result['pve-a:node-a:141']).toMatchObject({
|
||||
label: 'Docker',
|
||||
count: 1,
|
||||
href: '/docker',
|
||||
href: '/docker/overview?host=media-lxc.mist-stork.ts.net',
|
||||
items: [{ name: 'frigate', status: 'running' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the Docker overview when nested containers do not share one host label', () => {
|
||||
const lxc = makeGuest({
|
||||
id: 'lxc-141',
|
||||
vmid: 141,
|
||||
name: 'media-lxc',
|
||||
node: 'node-a',
|
||||
instance: 'pve-a',
|
||||
platformScopes: ['proxmox-pve'],
|
||||
});
|
||||
const firstContainer = makeGuest({
|
||||
id: 'app-container:first',
|
||||
vmid: 0,
|
||||
name: 'first',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
dockerHostName: 'first-host.local',
|
||||
});
|
||||
const secondContainer = makeGuest({
|
||||
id: 'app-container:second',
|
||||
vmid: 0,
|
||||
name: 'second',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformType: 'docker',
|
||||
platformScopes: ['proxmox-pve', 'docker'],
|
||||
containerRuntime: 'docker',
|
||||
dockerHostId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
dockerHostName: 'second-host.local',
|
||||
});
|
||||
|
||||
const result = buildNestedWorkloadContextByGuestId({
|
||||
guests: [lxc, firstContainer, secondContainer],
|
||||
visibleGuests: [lxc],
|
||||
excludedWorkloadTypes: ['app-container'],
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview');
|
||||
});
|
||||
|
||||
it('does not guess when Docker host identity only matches ambiguous VMIDs', () => {
|
||||
const firstLxc = makeGuest({
|
||||
id: 'lxc-a-141',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
|
||||
import { buildDockerPath, buildDockerRouteSearch } from '@/routing/resourceLinks';
|
||||
import {
|
||||
getCanonicalWorkloadId,
|
||||
resolveWorkloadType,
|
||||
|
|
@ -6,7 +7,6 @@ import {
|
|||
} from '@/utils/workloads';
|
||||
|
||||
const LXC_DOCKER_HOST_PREFIX = 'proxmox-lxc-docker:';
|
||||
const NESTED_WORKLOAD_DETAILS_PATH = '/docker';
|
||||
|
||||
export interface NestedWorkloadContextItem {
|
||||
id: string;
|
||||
|
|
@ -33,6 +33,10 @@ interface BuildNestedWorkloadContextParams {
|
|||
platformScope?: string | null;
|
||||
}
|
||||
|
||||
type NestedWorkloadContextBuildItem = NestedWorkloadContextItem & {
|
||||
hostLabel: string;
|
||||
};
|
||||
|
||||
const cleanText = (value?: string | number | null): string => String(value ?? '').trim();
|
||||
|
||||
const normalizeCandidate = (value?: string | number | null): string =>
|
||||
|
|
@ -103,11 +107,12 @@ const formatRuntimeLabel = (runtime?: string | null): string => {
|
|||
|
||||
const formatStatusLabel = (status?: string | null): string => cleanText(status) || 'unknown';
|
||||
|
||||
const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextItem => ({
|
||||
const createNestedItem = (guest: WorkloadGuest): NestedWorkloadContextBuildItem => ({
|
||||
id: getCanonicalWorkloadId(guest),
|
||||
name: cleanText(guest.name) || cleanText(guest.containerId) || getCanonicalWorkloadId(guest),
|
||||
status: formatStatusLabel(guest.status),
|
||||
runtimeLabel: formatRuntimeLabel(guest.containerRuntime),
|
||||
hostLabel: cleanText(guest.dockerHostName) || cleanText(guest.contextLabel),
|
||||
});
|
||||
|
||||
const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string => {
|
||||
|
|
@ -116,6 +121,23 @@ const chooseContextLabel = (items: readonly NestedWorkloadContextItem[]): string
|
|||
return 'Containers';
|
||||
};
|
||||
|
||||
const chooseDockerHostFilter = (items: readonly NestedWorkloadContextBuildItem[]): string => {
|
||||
const labels = Array.from(new Set(items.map((item) => item.hostLabel).filter(Boolean)));
|
||||
return labels.length === 1 ? labels[0] : '';
|
||||
};
|
||||
|
||||
const buildNestedWorkloadHref = (host: string): string =>
|
||||
`${buildDockerPath('overview')}${buildDockerRouteSearch({ host })}`;
|
||||
|
||||
const toNestedWorkloadContextItem = (
|
||||
item: NestedWorkloadContextBuildItem,
|
||||
): NestedWorkloadContextItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
runtimeLabel: item.runtimeLabel,
|
||||
});
|
||||
|
||||
export const buildNestedWorkloadContextByGuestId = ({
|
||||
guests,
|
||||
visibleGuests,
|
||||
|
|
@ -142,7 +164,7 @@ export const buildNestedWorkloadContextByGuestId = ({
|
|||
}
|
||||
}
|
||||
|
||||
const itemsByParentId = new Map<string, NestedWorkloadContextItem[]>();
|
||||
const itemsByParentId = new Map<string, NestedWorkloadContextBuildItem[]>();
|
||||
for (const guest of guests) {
|
||||
if (resolveWorkloadType(guest) !== 'app-container') continue;
|
||||
if (!workloadMatchesPlatformScope(guest, platformScope)) continue;
|
||||
|
|
@ -166,13 +188,14 @@ export const buildNestedWorkloadContextByGuestId = ({
|
|||
for (const [parentId, items] of itemsByParentId) {
|
||||
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const label = chooseContextLabel(sortedItems);
|
||||
const host = chooseDockerHostFilter(sortedItems);
|
||||
contexts[parentId] = {
|
||||
type: 'app-container',
|
||||
label,
|
||||
title: label === 'Containers' ? 'Nested containers' : `Nested ${label}`,
|
||||
count: sortedItems.length,
|
||||
href: NESTED_WORKLOAD_DETAILS_PATH,
|
||||
items: sortedItems,
|
||||
href: buildNestedWorkloadHref(host),
|
||||
items: sortedItems.map(toNestedWorkloadContextItem),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useBreakpoint } from '@/hooks/useBreakpoint';
|
|||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
|
||||
import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
|
||||
import {
|
||||
PLATFORM_HEALTH_FILTER_OPTIONS,
|
||||
PlatformTableEmptyState,
|
||||
|
|
@ -194,8 +195,12 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
|
|||
// only appears once more than one host is present (a single-host fleet has
|
||||
// nothing to scope by).
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const hostFilter = () => (typeof searchParams.host === 'string' ? searchParams.host : '');
|
||||
const setHostFilter = (value: string) => setSearchParams({ host: value || null });
|
||||
const hostFilter = () => {
|
||||
const host = searchParams[DOCKER_QUERY_PARAMS.host];
|
||||
return typeof host === 'string' ? host : '';
|
||||
};
|
||||
const setHostFilter = (value: string) =>
|
||||
setSearchParams({ [DOCKER_QUERY_PARAMS.host]: value || null });
|
||||
const hostOptions = createMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const resource of props.resources) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useLocation } from '@solidjs/router';
|
||||
import { useLocation, useSearchParams } from '@solidjs/router';
|
||||
import { Show, createMemo, createSignal } from 'solid-js';
|
||||
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
|
||||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
|
|
@ -41,6 +41,9 @@ import {
|
|||
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
|
||||
import { DOCKER_QUERY_PARAMS } from '@/routing/resourceLinks';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
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';
|
||||
|
|
@ -50,6 +53,7 @@ const dockerIcon = () => <DockerIcon class="h-6 w-6 text-slate-400" />;
|
|||
|
||||
export function DockerPageSurface() {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { resources, loading, error, refetch } = useUnifiedResources({
|
||||
query: DOCKER_RESOURCE_QUERY,
|
||||
cacheKey: 'docker-workspace',
|
||||
|
|
@ -64,6 +68,10 @@ export function DockerPageSurface() {
|
|||
const activeTab = createMemo<DockerPageTabId>(() =>
|
||||
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
|
||||
);
|
||||
const hostFilter = createMemo(() => {
|
||||
const rawHost = searchParams[DOCKER_QUERY_PARAMS.host];
|
||||
return typeof rawHost === 'string' ? rawHost.trim() : '';
|
||||
});
|
||||
const agentUpdateTargetVersion = createMemo(
|
||||
() => updateStore.versionInfo()?.agentUpdateTargetVersion,
|
||||
);
|
||||
|
|
@ -125,6 +133,7 @@ export function DockerPageSurface() {
|
|||
<DockerOverview
|
||||
hosts={model().hosts}
|
||||
hostSourceCount={model().hosts.length}
|
||||
hostFilter={hostFilter()}
|
||||
containers={model().containers}
|
||||
incidents={model().incidents}
|
||||
/>
|
||||
|
|
@ -295,13 +304,29 @@ function DockerSwarm(props: { model: DockerPageModel }) {
|
|||
function DockerOverview(props: {
|
||||
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
|
||||
hostSourceCount: number;
|
||||
hostFilter: string;
|
||||
containers: ReturnType<typeof buildDockerPageModel>['containers'];
|
||||
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
|
||||
}) {
|
||||
const hostMatchesFilter = (resource: Resource, hostFilter: string): boolean => {
|
||||
const normalized = hostFilter.trim();
|
||||
if (!normalized) return true;
|
||||
return [
|
||||
resource.docker?.hostname,
|
||||
resource.name,
|
||||
resource.displayName,
|
||||
resource.id,
|
||||
resource.docker?.hostSourceId,
|
||||
].some((candidate) => asTrimmedString(candidate) === normalized);
|
||||
};
|
||||
const scopedHosts = createMemo(() =>
|
||||
props.hosts.filter((host) => hostMatchesFilter(host, props.hostFilter)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<DockerHostsTable
|
||||
resources={props.hosts}
|
||||
resources={scopedHosts()}
|
||||
sourceCount={props.hostSourceCount}
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Docker or Podman hosts"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ const setViewportWidth = (width: number) => {
|
|||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
setViewportWidth(1024);
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -237,6 +238,50 @@ describe('Docker native tables', () => {
|
|||
expect(screen.queryByText('docker 27.5.1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the URL host scope to container rows', () => {
|
||||
window.history.pushState({}, '', '/?host=edge-02');
|
||||
|
||||
renderInRouter(() => (
|
||||
<DockerContainersTable
|
||||
resources={[
|
||||
makeResource({
|
||||
id: 'container-1',
|
||||
type: 'app-container',
|
||||
name: 'edge-web',
|
||||
status: 'running',
|
||||
docker: {
|
||||
hostname: 'edge-01',
|
||||
runtime: 'docker',
|
||||
image: 'nginx:latest',
|
||||
containerState: 'running',
|
||||
},
|
||||
}),
|
||||
makeResource({
|
||||
id: 'container-2',
|
||||
type: 'app-container',
|
||||
name: 'edge-cache',
|
||||
status: 'running',
|
||||
docker: {
|
||||
hostname: 'edge-02',
|
||||
runtime: 'docker',
|
||||
image: 'redis:7.4',
|
||||
containerState: 'running',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
showToolbar={false}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('edge-cache')).toBeInTheDocument();
|
||||
expect(screen.getByText('edge-02')).toBeInTheDocument();
|
||||
expect(screen.queryByText('edge-web')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('edge-01')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Restarts column when the current containers have no restart signal', () => {
|
||||
setViewportWidth(1500);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DockerPageSurface } from '../DockerPageSurface';
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pathname: '/docker/overview',
|
||||
searchParams: {} as Record<string, string>,
|
||||
useUnifiedResources: vi.fn(),
|
||||
DockerHostsTable: vi.fn(
|
||||
(props: { resources: Resource[]; showToolbar?: boolean; emptyTitle: string }) => (
|
||||
|
|
@ -82,6 +83,7 @@ vi.mock('@solidjs/router', () => ({
|
|||
return mocks.pathname;
|
||||
},
|
||||
}),
|
||||
useSearchParams: () => [mocks.searchParams, vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock('../DockerContainersTable', () => ({
|
||||
|
|
@ -213,6 +215,7 @@ const makeDockerNetwork = (overrides: Partial<Resource> = {}): Resource => ({
|
|||
|
||||
beforeEach(() => {
|
||||
mocks.pathname = '/docker/overview';
|
||||
mocks.searchParams = {};
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
error: () => null,
|
||||
loading: () => false,
|
||||
|
|
@ -276,6 +279,55 @@ describe('DockerPageSurface', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('applies the URL host scope to the overview host table', () => {
|
||||
mocks.searchParams = { host: 'frigate.mist-stork.ts.net' };
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
error: () => null,
|
||||
loading: () => false,
|
||||
refetch: vi.fn(),
|
||||
resources: () => [
|
||||
makeDockerHost({
|
||||
id: 'agent:frigate',
|
||||
name: 'frigate',
|
||||
displayName: 'frigate',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
runtimeVersion: '29.5.1',
|
||||
hostname: 'frigate.mist-stork.ts.net',
|
||||
containerCount: 2,
|
||||
} as NonNullable<Resource['docker']>,
|
||||
}),
|
||||
makeDockerHost({
|
||||
id: 'agent:tower',
|
||||
name: 'tower',
|
||||
displayName: 'tower',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
runtimeVersion: '29.5.2',
|
||||
hostname: 'tower',
|
||||
containerCount: 1,
|
||||
} as NonNullable<Resource['docker']>,
|
||||
}),
|
||||
makeDockerContainer({
|
||||
id: 'app-container:frigate',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
hostname: 'frigate.mist-stork.ts.net',
|
||||
image: 'ghcr.io/blakeblackshear/frigate:stable',
|
||||
} as NonNullable<Resource['docker']>,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(() => <DockerPageSurface />);
|
||||
|
||||
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
|
||||
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
|
||||
'data-resource-count',
|
||||
'1',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes stale agent notices to the agent install commands', () => {
|
||||
mocks.useUnifiedResources.mockReturnValue({
|
||||
error: () => null,
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ describe('useWorkloads', () => {
|
|||
docker: {
|
||||
containerId: 'frigate',
|
||||
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141',
|
||||
hostname: 'homepage-docker.lab',
|
||||
runtime: 'docker',
|
||||
image: 'ghcr.io/blakeblackshear/frigate:stable',
|
||||
},
|
||||
|
|
@ -310,6 +311,7 @@ describe('useWorkloads', () => {
|
|||
expect(byName.get('frigate')?.platformType).toBe('docker');
|
||||
expect(byName.get('frigate')?.platformScopes).toEqual(['proxmox-pve', 'docker']);
|
||||
expect(byName.get('frigate')?.dockerHostId).toBe('proxmox-lxc-docker:pve-a:node-a:141');
|
||||
expect(byName.get('frigate')?.dockerHostName).toBe('homepage-docker.lab');
|
||||
expect(byName.get('crm-app-01')?.platformType).toBe('vmware-vsphere');
|
||||
expect(byName.get('crm-app-01')?.node).toBe('esxi-01');
|
||||
expect(byName.get('crm-app-01')?.instance).toBe('Compute-A');
|
||||
|
|
|
|||
|
|
@ -543,6 +543,10 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
|||
: undefined,
|
||||
updateStatus: resource.docker?.updateStatus as WorkloadGuest['updateStatus'] | undefined,
|
||||
dockerHostId: dockerManagedAppContainer ? resource.docker?.hostSourceId : undefined,
|
||||
dockerHostName:
|
||||
workloadType === 'app-container'
|
||||
? (resource.docker?.hostname || '').trim() || undefined
|
||||
: undefined,
|
||||
kubernetesAgentId: workloadType === 'pod' ? resource.kubernetes?.agentId : undefined,
|
||||
platformType,
|
||||
platformScopes,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DOCKER_PATH,
|
||||
DOCKER_QUERY_PARAMS,
|
||||
KUBERNETES_PATH,
|
||||
PMG_THRESHOLDS_PATH,
|
||||
PATROL_PATH,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
TRUENAS_PATH,
|
||||
VMWARE_PATH,
|
||||
buildDockerPath,
|
||||
buildDockerRouteSearch,
|
||||
buildKubernetesPath,
|
||||
buildRecoveryRouteSearch,
|
||||
buildProxmoxPath,
|
||||
|
|
@ -50,6 +52,14 @@ describe('resource link routing contract', () => {
|
|||
expect(buildDockerPath()).toBe('/docker/overview');
|
||||
expect(buildDockerPath('containers')).toBe('/docker/containers');
|
||||
expect(buildDockerPath('')).toBe('/docker');
|
||||
expect(DOCKER_QUERY_PARAMS.host).toBe('host');
|
||||
expect(buildDockerRouteSearch({ host: 'frigate.mist-stork.ts.net' })).toBe(
|
||||
'?host=frigate.mist-stork.ts.net',
|
||||
);
|
||||
expect(buildDockerRouteSearch({ host: ' host with spaces ' })).toBe(
|
||||
'?host=host+with+spaces',
|
||||
);
|
||||
expect(buildDockerRouteSearch({ host: '' })).toBe('');
|
||||
|
||||
expect(KUBERNETES_PATH).toBe('/kubernetes');
|
||||
expect(buildKubernetesPath()).toBe('/kubernetes/overview');
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export const VMWARE_DEFAULT_TAB = 'overview';
|
|||
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
|
||||
export const PATROL_PATH = '/patrol';
|
||||
|
||||
export const DOCKER_QUERY_PARAMS = {
|
||||
host: 'host',
|
||||
} as const;
|
||||
|
||||
export const STORAGE_QUERY_PARAMS = {
|
||||
tab: 'tab',
|
||||
group: 'group',
|
||||
|
|
@ -97,6 +101,10 @@ type WorkloadsLinkOptions = {
|
|||
summaryGroup?: string | null;
|
||||
};
|
||||
|
||||
type DockerLinkOptions = {
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
type StorageLinkOptions = {
|
||||
tab?: string | null;
|
||||
group?: string | null;
|
||||
|
|
@ -191,6 +199,13 @@ export const buildDockerPath = (tab: string = DOCKER_DEFAULT_TAB): string => {
|
|||
return normalized ? `${DOCKER_PATH}/${normalized}` : DOCKER_PATH;
|
||||
};
|
||||
|
||||
export const buildDockerRouteSearch = (options: DockerLinkOptions = {}): string => {
|
||||
const params = new URLSearchParams();
|
||||
const host = normalizeQueryValue(options.host);
|
||||
if (host) params.set(DOCKER_QUERY_PARAMS.host, host);
|
||||
return serializedRouteSearch(params);
|
||||
};
|
||||
|
||||
export const buildKubernetesPath = (tab: string = KUBERNETES_DEFAULT_TAB): string => {
|
||||
const normalized = tab.trim().replace(/^\/+|\/+$/g, '');
|
||||
return normalized ? `${KUBERNETES_PATH}/${normalized}` : KUBERNETES_PATH;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export type WorkloadGuest = (VM | Container) & {
|
|||
// Agent-managed Docker runtime host ID for Docker-specific update actions.
|
||||
// API-backed platforms such as TrueNAS must not populate this field.
|
||||
dockerHostId?: string;
|
||||
// Docker/Podman host label used by the Docker runtime page's host facet.
|
||||
dockerHostName?: string;
|
||||
// Kubernetes agent ID (when available) — preferred for actionable operations.
|
||||
kubernetesAgentId?: string;
|
||||
// Canonical discovery ownership. API-backed platforms such as TrueNAS should
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue