add port/mount display

This commit is contained in:
Alex Holliday committed 2026-09-02 09:45:28 -07:00
1 parent 56153b38a5
commit 786c803847
21 files changed
+441 -24

No files matched your search

@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps {
isLoading?: boolean;
hasDateRange?: boolean;
dateRange: string;
setDateRange: (dateRange: string) => void;
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
}
export const HeaderTimeRange = ({
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: string | null
newValue: DateRange | null
) => {
if (newValue !== null) {
setDateRange(newValue);
+2 -1
View File
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
import type { DateRange } from "@/Types/Query";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
@@ -1,9 +1,10 @@
// Components
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
// Hooks
import { useTheme } from "@mui/material";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -11,9 +12,11 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
@@ -35,6 +38,8 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
<Grid
container
@@ -57,6 +62,52 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
</Grid>
);
})}
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Ports
</Typography>
<Stack>
{dedupedPorts?.map((port) => {
return (
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Volumes
</Typography>
<Stack>
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
</Grid>
);
};
@@ -1,7 +1,7 @@
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/Details/TabOverview";
import { TabLogs } from "@/Pages/Docker/Details/TabLogs";
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
@@ -9,7 +9,9 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { DockerContainerResponse } from "@/Types/Monitor";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
import type { DateRange } from "@/Types/Query";
import { HeaderTimeRange } from "@/Components/common";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
@@ -21,19 +23,20 @@ const TAB_MAP = {
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
monitorId: string;
containerName: string;
}>();
const { t } = useTranslation();
const monitorDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}`
const containerDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
: null;
const { data: containerData, refetch: refetchContainer } =
useGet<DockerContainerResponse>(
monitorDetailsUrl,
containerDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
@@ -59,6 +62,10 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
container={container}
monitor={monitor}
/>
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
+2 -1
View File
@@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import type { DateRange } from "@/Types/Query";
import { useTheme } from "@mui/material";
const IncidentsPage = () => {
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => {
const { t } = useTranslation();
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => {
+2 -1
View File
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
import type { DateRange } from "@/Types/Query";
const PageSpeedDetails = () => {
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
const [dateRange, setDateRange] = useState<string>("day");
const [dateRange, setDateRange] = useState<DateRange>("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
+2 -1
View File
@@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
import type { DateRange } from "@/Types/Query";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -53,7 +54,7 @@ const UptimeDetailsPage = () => {
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
+1 -1
View File
@@ -23,7 +23,7 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details";
// Docker
import Docker from "@/Pages/Docker/Monitors";
import DockerHostDetails from "@/Pages/Docker/HostDetails";
import DockerDetails from "@/Pages/Docker/Details";
import DockerDetails from "@/Pages/Docker/ContainerDetails";
// Checks
import Checks from "@/Pages/Checks";
+23 -4
View File
@@ -139,11 +139,28 @@ export const DockerContainerStates = [
] as const;
export type DockerContainerState = (typeof DockerContainerStates)[number];
// Mirrors DockerHealthStatuses in server/src/types/network.ts.
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
// Mirrors DockerContainerInfo in server/src/types/network.ts.
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
export interface DockerContainerPort {
privatePort: number;
protocol: DockerPortProtocol;
publicPort?: number;
hostIp?: string;
}
export interface DockerContainerMount {
type: string;
name?: string;
source: string;
destination: string;
mode: string;
rw: boolean;
}
export interface DockerContainerInfo {
id: string;
name: string;
@@ -151,12 +168,14 @@ export interface DockerContainerInfo {
state: DockerContainerState;
status: string;
health: DockerHealthStatus;
cpuPct?: number; // fraction of one core; exceeds 1 when using multiple cores
cpuPct?: number;
memoryUsedBytes?: number;
memoryLimitBytes?: number;
memoryPct?: number; // 0-1 fraction of the memory limit
memoryPct?: number;
restartCount?: number;
startedAt?: string;
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
}
export interface Check {
+2
View File
@@ -0,0 +1,2 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
+34 -1
View File
@@ -1,7 +1,11 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import type { DockerContainerState } from "@/Types/Check";
import type {
DockerContainerMount,
DockerContainerPort,
DockerContainerState,
} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
@@ -111,3 +115,32 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl;
};
export const dedupeDockerPorts = (
ports: DockerContainerPort[]
): DockerContainerPort[] => {
return ports.filter((port) => {
// Discard ipv6 wildcards
if (port.hostIp !== "::") return port;
const isDuplicate = ports.some((otherPort) => {
if (otherPort.hostIp !== "0.0.0.0") return false;
return (
otherPort.privatePort === port.privatePort &&
otherPort.protocol === port.protocol &&
otherPort.publicPort === port.publicPort
);
});
return !isDuplicate;
});
};
const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.type === "volume" && mount.name)
return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
if (mount.source) return mount.source;
return mount.type;
};
+98
View File
@@ -7295,6 +7295,55 @@
},
"startedAt": {
"type": "string"
},
"ports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"privatePort": {
"type": "number"
},
"protocol": {
"type": "string",
"enum": ["tcp", "udp", "sctp"]
},
"publicPort": {
"type": "number"
},
"hostIp": {
"type": "string"
}
},
"required": ["privatePort", "protocol"]
}
},
"mounts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"source": {
"type": "string"
},
"destination": {
"type": "string"
},
"mode": {
"type": "string"
},
"rw": {
"type": "boolean"
}
},
"required": ["type", "source", "destination", "mode", "rw"]
}
}
},
"required": ["id", "name", "image", "state", "status", "health"]
@@ -7792,6 +7841,55 @@
},
"startedAt": {
"type": "string"
},
"ports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"privatePort": {
"type": "number"
},
"protocol": {
"type": "string",
"enum": ["tcp", "udp", "sctp"]
},
"publicPort": {
"type": "number"
},
"hostIp": {
"type": "string"
}
},
"required": ["privatePort", "protocol"]
}
},
"mounts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"source": {
"type": "string"
},
"destination": {
"type": "string"
},
"mode": {
"type": "string"
},
"rw": {
"type": "boolean"
}
},
"required": ["type", "source", "destination", "mode", "rw"]
}
}
},
"required": ["id", "name", "image", "state", "status", "health"]
+20 -1
View File
@@ -13,7 +13,7 @@ import {
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses } from "@/types/network.js";
import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -430,6 +430,23 @@ export const uptimeDetailsResponseSchema = z.object({
monitorStats: monitorStatsResponseSchema.nullable(),
});
// Keep aligned with DockerContainerPort / DockerContainerMount in types/network.ts.
export const dockerContainerPortResponseSchema = z.object({
privatePort: z.number(),
protocol: z.enum(DockerPortProtocols),
publicPort: z.number().optional(),
hostIp: z.string().optional(),
});
export const dockerContainerMountResponseSchema = z.object({
type: z.string(),
name: z.string().optional(),
source: z.string(),
destination: z.string(),
mode: z.string(),
rw: z.boolean(),
});
// Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts.
export const dockerContainerResponseSchema = z.object({
id: z.string(),
@@ -444,6 +461,8 @@ export const dockerContainerResponseSchema = z.object({
memoryPct: z.number().optional(),
restartCount: z.number().optional(),
startedAt: z.string().optional(),
ports: z.array(dockerContainerPortResponseSchema).optional(),
mounts: z.array(dockerContainerMountResponseSchema).optional(),
});
export const containerSummaryResponseSchema = z.object({
+33 -1
View File
@@ -14,7 +14,15 @@ import type {
GotTimings,
ILighthouseAudit,
} from "@/domain/checks/check.type.js";
import { DockerContainerInfo, DockerContainerStates, DockerContainerSummary, DockerHealthStatuses } from "@/types/network.js";
import {
DockerContainerInfo,
DockerContainerMount,
DockerContainerPort,
DockerContainerStates,
DockerContainerSummary,
DockerHealthStatuses,
DockerPortProtocols,
} from "@/types/network.js";
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
@@ -173,6 +181,28 @@ const auditsSchema = new Schema<CheckAudits>(
{ _id: false }
);
const dockerContainerPortSchema = new Schema<DockerContainerPort>(
{
privatePort: { type: Number, required: true },
protocol: { type: String, enum: DockerPortProtocols, default: "tcp" },
publicPort: { type: Number },
hostIp: { type: String },
},
{ _id: false }
);
const dockerContainerMountSchema = new Schema<DockerContainerMount>(
{
type: { type: String, default: "" },
name: { type: String },
source: { type: String, default: "" },
destination: { type: String, default: "" },
mode: { type: String, default: "" },
rw: { type: Boolean, default: true },
},
{ _id: false }
);
const dockerContainerSchema = new Schema<DockerContainerInfo>(
{
id: { type: String, required: true },
@@ -187,6 +217,8 @@ const dockerContainerSchema = new Schema<DockerContainerInfo>(
memoryPct: { type: Number },
restartCount: { type: Number },
startedAt: { type: String },
ports: { type: [dockerContainerPortSchema], default: undefined },
mounts: { type: [dockerContainerMountSchema], default: undefined },
},
{ _id: false }
);
@@ -1,11 +1,15 @@
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
import {
DockerContainerInfo,
DockerContainerMount,
DockerContainerPort,
DockerContainerState,
DockerContainerStates,
DockerContainerSummary,
DockerHealthStatus,
DockerHealthStatuses,
DockerPortProtocol,
DockerPortProtocols,
DockerStatusPayload,
MonitorStatusResponse,
} from "@/types/network.js";
@@ -135,6 +139,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
info.restartCount = inspectResult.value.RestartCount;
info.startedAt = inspectResult.value.State?.StartedAt;
info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status);
info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports);
info.mounts = this.toMounts(inspectResult.value.Mounts);
} else {
this.logger.warn({
message: `Failed to inspect container ${info.name}`,
@@ -152,6 +158,46 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
return info;
}
private toPortProtocol(protocol: string): DockerPortProtocol {
return (DockerPortProtocols as readonly string[]).includes(protocol) ? (protocol as DockerPortProtocol) : "tcp";
}
private toPorts(ports: Dockerode.ContainerInspectInfo["NetworkSettings"]["Ports"] | undefined): DockerContainerPort[] {
const result: DockerContainerPort[] = [];
for (const [portAndProtocol, bindings] of Object.entries(ports ?? {})) {
const [portString, protocolString = ""] = portAndProtocol.split("/");
const privatePort = Number(portString);
if (!Number.isInteger(privatePort)) continue;
const protocol = this.toPortProtocol(protocolString);
// Exposed but unpublished ports have no bindings
if (!bindings || bindings.length === 0) {
result.push({ privatePort, protocol });
continue;
}
for (const binding of bindings) {
const publicPort = Number(binding.HostPort);
result.push({
privatePort,
protocol,
publicPort: Number.isInteger(publicPort) ? publicPort : undefined,
hostIp: binding.HostIp || undefined,
});
}
}
return result;
}
private toMounts(mounts: Dockerode.ContainerInspectInfo["Mounts"] | undefined): DockerContainerMount[] {
return (mounts ?? []).map((mount) => ({
type: mount.Type ?? "",
name: mount.Name,
source: mount.Source ?? "",
destination: mount.Destination ?? "",
mode: mount.Mode ?? "",
rw: mount.RW ?? true,
}));
}
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
try {
const docker = new this.DockerLib(this.toDockerOptions(monitor));
+21
View File
@@ -123,6 +123,27 @@ export interface DockerContainerInfo {
memoryPct?: number; // 0-1 fraction of the memory limit
restartCount?: number;
startedAt?: string; // ISO date
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
}
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
export interface DockerContainerPort {
privatePort: number;
protocol: DockerPortProtocol;
publicPort?: number;
hostIp?: string;
}
export interface DockerContainerMount {
type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, …
name?: string;
source: string;
destination: string;
mode: string;
rw: boolean;
}
export interface DockerStatusPayload {
@@ -375,6 +375,89 @@ describe("DockerProvider", () => {
});
});
// ── Ports and mounts ─────────────────────────────────────────────────
describe("ports and mounts", () => {
it("maps published and unpublished ports and mounts from inspect", async () => {
const inspect = jest.fn().mockResolvedValue(
makeInspect({
NetworkSettings: {
Ports: {
"80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }],
"443/tcp": null,
},
},
Mounts: [
{
Type: "volume",
Name: "mongo_data",
Source: "/var/lib/docker/volumes/mongo_data/_data",
Destination: "/data/db",
Mode: "z",
RW: true,
},
{ Type: "bind", Source: "/srv/config", Destination: "/etc/app", Mode: "ro", RW: false },
],
})
);
const { provider } = setup({ inspect });
const result = await provider.handle(makeMonitor());
const container = result.payload?.containers[0];
expect(container?.ports).toEqual([
{ privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" },
{ privatePort: 443, protocol: "tcp" },
]);
expect(container?.mounts).toEqual([
{ type: "volume", name: "mongo_data", source: "/var/lib/docker/volumes/mongo_data/_data", destination: "/data/db", mode: "z", rw: true },
{ type: "bind", name: undefined, source: "/srv/config", destination: "/etc/app", mode: "ro", rw: false },
]);
});
it("emits one port entry per host binding", async () => {
const inspect = jest.fn().mockResolvedValue(
makeInspect({
NetworkSettings: {
Ports: {
"80/tcp": [
{ HostIp: "0.0.0.0", HostPort: "8080" },
{ HostIp: "::", HostPort: "8080" },
],
},
},
})
);
const { provider } = setup({ inspect });
const result = await provider.handle(makeMonitor());
expect(result.payload?.containers[0]?.ports).toEqual([
{ privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" },
{ privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "::" },
]);
});
it("maps missing ports and mounts to empty arrays", async () => {
const { provider } = setup();
const result = await provider.handle(makeMonitor());
expect(result.payload?.containers[0]?.ports).toEqual([]);
expect(result.payload?.containers[0]?.mounts).toEqual([]);
});
it("leaves ports and mounts undefined when inspect fails", async () => {
const inspect = jest.fn().mockRejectedValue(new Error("inspect failed"));
const { provider } = setup({ inspect });
const result = await provider.handle(makeMonitor());
expect(result.payload?.containers[0]?.ports).toBeUndefined();
expect(result.payload?.containers[0]?.mounts).toBeUndefined();
});
});
// ── Outer error handling ─────────────────────────────────────────────
describe("outer error handling", () => {