From 786c8038479cdc5a9d8d28b26ac82ed89e988f13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:45:28 -0700 Subject: [PATCH] add port/mount display --- .../common/controls/HeaderTimeRange.tsx | 7 +- client/src/Pages/Checks/index.tsx | 3 +- .../Components/Charts.tsx | 0 .../Components/StatBoxes.tsx | 0 .../{Details => ContainerDetails}/TabLogs.tsx | 0 .../TabOverview.tsx | 55 ++++++++++- .../{Details => ContainerDetails}/index.tsx | 19 ++-- client/src/Pages/Incidents/index.tsx | 3 +- .../Pages/Infrastructure/Details/index.tsx | 3 +- client/src/Pages/PageSpeed/Details/index.tsx | 3 +- client/src/Pages/Uptime/Details/index.tsx | 3 +- client/src/Routes/index.tsx | 2 +- client/src/Types/Check.ts | 27 ++++- client/src/Types/Query.ts | 2 + client/src/Utils/MonitorUtils.ts | 35 ++++++- server/openapi.json | 98 +++++++++++++++++++ .../src/api/validation/monitorValidation.ts | 21 +++- server/src/domain/checks/check.model.ts | 34 ++++++- server/src/service/network/DockerProvider.ts | 46 +++++++++ server/src/types/network.ts | 21 ++++ .../providers/network/dockerProvider.test.ts | 83 ++++++++++++++++ 21 files changed, 441 insertions(+), 24 deletions(-) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/Charts.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/StatBoxes.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabLogs.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabOverview.tsx (52%) rename client/src/Pages/Docker/{Details => ContainerDetails}/index.tsx (75%) create mode 100644 client/src/Types/Query.ts diff --git a/client/src/Components/common/controls/HeaderTimeRange.tsx b/client/src/Components/common/controls/HeaderTimeRange.tsx index d7c7102bc..254de71b2 100644 --- a/client/src/Components/common/controls/HeaderTimeRange.tsx +++ b/client/src/Components/common/controls/HeaderTimeRange.tsx @@ -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, - newValue: string | null + newValue: DateRange | null ) => { if (newValue !== null) { setDateRange(newValue); diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx index 93364af44..453660413 100644 --- a/client/src/Pages/Checks/index.tsx +++ b/client/src/Pages/Checks/index.tsx @@ -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(monitorId || "0"); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [statusFilter, setStatusFilter] = useState("down"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); diff --git a/client/src/Pages/Docker/Details/Components/Charts.tsx b/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/Charts.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx diff --git a/client/src/Pages/Docker/Details/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/StatBoxes.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx diff --git a/client/src/Pages/Docker/Details/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx similarity index 100% rename from client/src/Pages/Docker/Details/TabLogs.tsx rename to client/src/Pages/Docker/ContainerDetails/TabLogs.tsx diff --git a/client/src/Pages/Docker/Details/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx similarity index 52% rename from client/src/Pages/Docker/Details/TabOverview.tsx rename to client/src/Pages/Docker/ContainerDetails/TabOverview.tsx index ce746cc19..47503bff3 100644 --- a/client/src/Pages/Docker/Details/TabOverview.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx @@ -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 ( { ); })} + + + + Ports + + + {dedupedPorts?.map((port) => { + return ( + {`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`} + ); + })} + + + + + + + Volumes + + + {mounts?.map((mount) => { + return ( + + {`${getDockerMountLabel(mount)} -> ${mount.destination}`} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx similarity index 75% rename from client/src/Pages/Docker/Details/index.tsx rename to client/src/Pages/Docker/ContainerDetails/index.tsx index 33ef7da9e..e3462d86e 100644 --- a/client/src/Pages/Docker/Details/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -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(TAB_MAP[open]); + const [dateRange, setDateRange] = useState("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( - monitorDetailsUrl, + containerDetailsUrl, {}, { refreshInterval: 10000, keepPreviousData: true } ); @@ -59,6 +62,10 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = container={container} monitor={monitor} /> + setActiveTab(newValue)} diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 63cd18889..b1d18a8a4 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -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("recent"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx index 202887ad5..ad1d4a334 100644 --- a/client/src/Pages/Infrastructure/Details/index.tsx +++ b/client/src/Pages/Infrastructure/Details/index.tsx @@ -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("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedTab, setSelectedTab] = useState(0); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index ea9da4a7f..9b4e40b7c 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -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("day"); + const [dateRange, setDateRange] = useState("day"); const monitorDetailsUrl = useMemo(() => { if (!monitorId) { diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index bcf7177ac..13998aed5 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -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(5); const [geoPage, setGeoPage] = useState(0); const [geoRowsPerPage, setGeoRowsPerPage] = useState(5); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedLocation, setSelectedLocation] = useState("NA"); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index a3fccf378..37e49d1ee 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -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"; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 6e5ca29f8..419ff7736 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -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 { diff --git a/client/src/Types/Query.ts b/client/src/Types/Query.ts new file mode 100644 index 000000000..6c0b819b9 --- /dev/null +++ b/client/src/Types/Query.ts @@ -0,0 +1,2 @@ +export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const; +export type DateRange = (typeof DateRanges)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index ef48c2223..3a7778b75 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -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 = { @@ -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; +}; diff --git a/server/openapi.json b/server/openapi.json index cd6a3466d..cf058ec4c 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -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"] diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 3b94f6989..2d3a16c49 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -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({ diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index a101375f4..cdab5fec7 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -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 & { monitorId: Types.ObjectId; @@ -173,6 +181,28 @@ const auditsSchema = new Schema( { _id: false } ); +const dockerContainerPortSchema = new Schema( + { + privatePort: { type: Number, required: true }, + protocol: { type: String, enum: DockerPortProtocols, default: "tcp" }, + publicPort: { type: Number }, + hostIp: { type: String }, + }, + { _id: false } +); + +const dockerContainerMountSchema = new Schema( + { + 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( { id: { type: String, required: true }, @@ -187,6 +217,8 @@ const dockerContainerSchema = new Schema( memoryPct: { type: Number }, restartCount: { type: Number }, startedAt: { type: String }, + ports: { type: [dockerContainerPortSchema], default: undefined }, + mounts: { type: [dockerContainerMountSchema], default: undefined }, }, { _id: false } ); diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 152729f10..8d515b604 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -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 { 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 { 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> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 25ee220ac..ae1cc78d2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -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 { diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 74ca2d8db..4a9e3fe52 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -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", () => {