add port/mount display
This commit is contained in:
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+53
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
+13
-6
@@ -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)}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||
export type DateRange = (typeof DateRanges)[number];
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"]
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user