Merge pull request #3912 from bluewave-labs/feat/docker-container-details

feat: docker container details
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-09-02 09:53:58 -07:00
commit a1c54997bb
42 files changed
+1641 -163

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);
@@ -10,6 +10,7 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode;
title: string;
rightTitle?: string;
width?: number | string;
maxWidth?: number | string;
flexBasis?: number | string;
@@ -20,6 +21,7 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
rightTitle,
width = "100%",
maxWidth = "100%",
flexBasis = "0%",
@@ -47,13 +49,27 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
<Stack
direction="row"
justifyContent={"space-between"}
>
{title}
</Typography>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -0,0 +1,113 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -0,0 +1,70 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
export const HistogramInfrastructure = ({
dateRange,
+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);
Whitespace-only changes.
@@ -0,0 +1,51 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
export const DockerContainerStatusBoxes = ({
container,
}: {
container: DockerContainerInfo | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!container) return null;
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
subtitle={health}
/>
</Stack>
);
};
@@ -0,0 +1,3 @@
export const TabLogs = () => {
return "This is the logs tab";
};
@@ -0,0 +1,113 @@
// Components
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
// Hooks
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
// Types
import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } 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) => [
{
title: t("common.charts.labels.cpuUsage"),
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
},
];
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
<Grid
container
spacing={LAYOUT.MD}
>
{getChartConfigs(theme, t, stats).map((config) => {
return (
<Grid
key={config.key}
size={isSmall ? 12 : 6}
>
<HistogramDockerContainer
title={config.title}
rightTitle={config.rightTitle}
stats={stats}
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
/>
</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>
);
};
@@ -0,0 +1,78 @@
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
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/ContainerDetails/Components/StatBoxes";
import type { DateRange } from "@/Types/Query";
import { HeaderTimeRange } from "@/Components/common";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
const TAB_MAP = {
overview: 0,
logs: 1,
} as const;
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 containerDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
: null;
const { data: containerData } = useGet<DockerContainerResponse>(
containerDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = containerData?.monitor;
if (!monitor) return null;
const stats = containerData.stats;
const container = stats?.latest?.container;
if (!container) return null;
const { state, name, image } = container;
return (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes container={container} />
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && <TabLogs />}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -1,5 +0,0 @@
const DockerDetailsPage = () => {
return null;
};
export default DockerDetailsPage;
@@ -15,11 +15,15 @@ import type { DockerContainerInfo } from "@/Types/Check";
import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
containers: DockerContainerInfo[];
onRowClick?: (container: DockerContainerInfo) => void;
}
export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => {
export const DockerContainersTable = ({
monitorId,
containers,
}: DockerContainersTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -64,7 +68,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
},
{
id: "health",
content: t("pages.docker.host.table.headers.health"),
content: t("common.labels.health"),
render: (row) => {
return <Typography>{row.health}</Typography>;
},
@@ -85,7 +89,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
},
{
id: "restarts",
content: t("pages.docker.host.table.headers.restarts"),
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
@@ -100,14 +104,13 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
headers = headers.filter((h) => h.id !== "histogram");
}
console.log(JSON.stringify(containers, null, 2));
return (
<Box>
<Table
headers={headers}
data={containers}
onRowClick={(row) => {
navigate(`/docker/container/${row.id}`);
navigate(`/docker/host/${monitorId}/container/${row.name}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
@@ -44,7 +44,10 @@ const DockerHostDetailsPage = () => {
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable containers={containers} />
<DockerContainersTable
monitorId={monitorId}
containers={containers}
/>
</BasePage>
);
};
+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);
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("pages.infrastructure.charts.labels.memory"),
title: t("common.charts.labels.memoryUsage"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.cpu"),
title: t("common.charts.labels.cpuUsage"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
@@ -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(() => {
+2 -2
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";
@@ -157,7 +157,7 @@ const Routes = () => {
element={<CreateMonitor />}
/>
<Route
path="docker/:monitorId"
path="docker/host/:monitorId/container/:containerName"
element={<DockerDetails />}
/>
<Route
+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 {
+21 -4
View File
@@ -270,7 +270,6 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
// Mirrors DockerStatsBucket in server/src/domain/checks/check.type.ts.
export interface DockerStatsBucket {
_id: string;
avgResponseTime: number | null;
@@ -281,7 +280,6 @@ export interface DockerStatsBucket {
avgUnhealthy: number | null;
}
// Mirrors DockerStats in server/src/domain/checks/check.type.ts.
export interface DockerStats {
aggregateData: {
totalChecks: number;
@@ -297,13 +295,32 @@ export interface DockerStats {
} | null;
}
// Response of GET /monitors/docker/details/{monitorId}; mirrors
// dockerDetailsResponseSchema in server/src/api/validation/monitorValidation.ts.
export interface DockerDetailsResponse {
monitor: Monitor;
stats: DockerStats;
monitorStats: MonitorStats | null;
}
export interface DockerContainerStatsBucket {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
}
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
export interface DockerContainerResponse {
monitor: Monitor;
stats: DockerContainerStats;
}
export interface Game {
name: string;
+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;
};
+18 -6
View File
@@ -66,9 +66,11 @@
"charts": {
"labels": {
"averageResponseTime": "Average response time",
"cpuUsage": "CPU usage",
"downtime": "Downtime",
"high": "High",
"low": "Low",
"memoryUsage": "Memory usage",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
@@ -102,7 +104,11 @@
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time"
"responseTime": "Response time",
"state": "State",
"uptime": "Uptime",
"health": "Health",
"restarts": "Restarts"
},
"form": {
"autocomplete": {
@@ -942,6 +948,16 @@
},
"containersRunning": "{{running}} of {{total}} running"
},
"container": {
"header": {
"title": "Container details",
"description": "State, resource usage, and logs for this container."
},
"tabs": {
"overview": "Overview",
"logs": "Logs"
}
},
"host": {
"statBoxes": {
"containers": "Containers",
@@ -952,10 +968,8 @@
"table": {
"headers": {
"container": "Containers",
"health": "Health",
"cpu": "CPU",
"memory": "Memory",
"restarts": "Restarts"
"memory": "Memory"
}
}
}
@@ -963,9 +977,7 @@
"infrastructure": {
"charts": {
"labels": {
"cpu": "CPU usage",
"disk": "Disk usage",
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",
+517
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"]
@@ -7467,6 +7516,474 @@
}
}
},
"/monitors/docker/details/{monitorId}/containers/{containerName}": {
"get": {
"tags": ["monitors"],
"summary": "Get details for one container on a Docker host monitor",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "monitorId",
"in": "path"
},
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "containerName",
"in": "path"
},
{
"schema": {
"type": "string",
"enum": ["recent", "hour", "day", "week", "month", "all"]
},
"required": false,
"name": "dateRange",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"monitor": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
},
"url": {
"type": "string"
},
"port": {
"type": "number"
},
"isActive": {
"type": "boolean"
},
"interval": {
"type": "number"
},
"status": {
"type": "string",
"enum": ["up", "down", "paused", "initializing", "maintenance", "breached"]
},
"statusWindowSize": {
"type": "number"
},
"statusWindowThreshold": {
"type": "number"
},
"ignoreTlsErrors": {
"type": "boolean"
},
"proxyMode": {
"type": "string",
"enum": ["inherit", "none", "custom"]
},
"proxyId": {
"type": "string"
},
"useAdvancedMatching": {
"type": "boolean"
},
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex"]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"notifications": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"selectedDisks": {
"type": "array",
"items": {
"type": "string"
}
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string"
},
"group": {
"type": "string",
"nullable": true
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"type": "array",
"items": {
"type": "string",
"enum": ["EU", "NA", "AS", "SA", "AF", "OC"]
}
},
"geoCheckInterval": {
"type": "number"
},
"dnsServer": {
"type": "string"
},
"dnsRecordType": {
"type": "string",
"enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
},
"teamId": {
"type": "string"
},
"userId": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"lastEvaluatedAt": {
"type": "number"
},
"id": {
"type": "string"
}
},
"required": [
"name",
"type",
"url",
"isActive",
"interval",
"status",
"statusWindowSize",
"statusWindowThreshold",
"ignoreTlsErrors",
"proxyMode",
"useAdvancedMatching",
"method",
"notifications",
"tags",
"cpuAlertThreshold",
"memoryAlertThreshold",
"diskAlertThreshold",
"tempAlertThreshold",
"selectedDisks",
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"teamId",
"userId",
"createdAt",
"updatedAt",
"lastEvaluatedAt",
"id"
],
"additionalProperties": {
"nullable": true
}
},
"stats": {
"type": "object",
"properties": {
"aggregate": {
"type": "array",
"items": {
"type": "object",
"properties": {
"_id": {
"type": "string"
},
"avgCpuPct": {
"type": "number",
"nullable": true
},
"avgMemoryUsedBytes": {
"type": "number",
"nullable": true
},
"avgMemoryPct": {
"type": "number",
"nullable": true
},
"minRestartCount": {
"type": "number",
"nullable": true
},
"maxRestartCount": {
"type": "number",
"nullable": true
}
},
"required": ["_id", "avgCpuPct", "avgMemoryUsedBytes", "avgMemoryPct", "minRestartCount", "maxRestartCount"]
}
},
"restartsInRange": {
"type": "number"
},
"latest": {
"type": "object",
"nullable": true,
"properties": {
"container": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"image": {
"type": "string"
},
"state": {
"type": "string",
"enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"]
},
"status": {
"type": "string"
},
"health": {
"type": "string",
"enum": ["healthy", "unhealthy", "starting", "none"]
},
"cpuPct": {
"type": "number"
},
"memoryUsedBytes": {
"type": "number"
},
"memoryLimitBytes": {
"type": "number"
},
"memoryPct": {
"type": "number"
},
"restartCount": {
"type": "number"
},
"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"]
},
"checkedAt": {
"type": "string"
}
},
"required": ["container", "checkedAt"]
}
},
"required": ["aggregate", "restartsInRange", "latest"]
}
},
"required": ["monitor", "stats"]
}
},
"required": ["success", "msg", "data"]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/monitors/pagespeed/details/{monitorId}": {
"get": {
"tags": ["monitors"],
+13
View File
@@ -18,7 +18,10 @@ import {
getHardwareDetailsByIdQueryValidation,
getDockerDetailsByIdParamValidation,
getDockerDetailsByIdQueryValidation,
getDockerContainerNameParamValidation,
getDockerContainerByNameQueryValidation,
dockerDetailsResponseSchema,
dockerContainerDetailsResponseSchema,
monitorResponseSchema,
uptimeDetailsResponseSchema,
} from "@/api/validation/monitorValidation.js";
@@ -118,6 +121,16 @@ registry.registerPath({
responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/docker/details/{monitorId}/containers/{containerName}",
tags,
summary: "Get details for one container on a Docker host monitor",
security: bearer,
request: { params: getDockerContainerNameParamValidation, query: getDockerContainerByNameQueryValidation },
responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
@@ -19,6 +19,8 @@ import {
bulkPauseMonitorBodyValidation,
getDockerDetailsByIdParamValidation,
getDockerDetailsByIdQueryValidation,
getDockerContainerNameParamValidation,
getDockerContainerByNameQueryValidation,
} from "@/api/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import * as whoiser from "whoiser";
@@ -34,6 +36,7 @@ export interface IMonitorController {
getHardwareDetailsById: RequestHandler;
getPageSpeedDetailsById: RequestHandler;
getDockerDetailsById: RequestHandler;
getDockerContainerByName: RequestHandler;
getGeoChecksByMonitorId: RequestHandler;
getMonitorById: RequestHandler;
createMonitor: RequestHandler;
@@ -173,6 +176,28 @@ class MonitorController implements IMonitorController {
});
});
getDockerContainerByName = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
const validatedQuery = getDockerContainerByNameQueryValidation.parse(req.query);
const { monitorId, containerName } = validatedParams;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getDockerContainerByName({
teamId,
monitorId,
containerName,
dateRange,
});
return res.status(200).json({
success: true,
msg: "Docker container retrieved successfully",
data,
});
});
getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
+1
View File
@@ -20,6 +20,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// Docker routes
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
+52 -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" });
@@ -326,6 +326,12 @@ export const getHardwareDetailsByIdQueryValidation = z.object({
export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") });
export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
export const getDockerContainerNameParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
containerName: z.string().min(1, "Container name is required"),
});
export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
// what the controllers actually serialize.
export const monitorResponseSchema = z
@@ -424,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(),
@@ -438,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({
@@ -478,3 +503,29 @@ export const dockerDetailsResponseSchema = z.object({
}),
monitorStats: monitorStatsResponseSchema.nullable(),
});
// Keep aligned with DockerContainerStatsBucket in domain/checks/check.type.ts.
export const dockerContainerStatsBucketResponseSchema = z.object({
_id: z.string(),
avgCpuPct: z.number().nullable(),
avgMemoryUsedBytes: z.number().nullable(),
avgMemoryPct: z.number().nullable(),
minRestartCount: z.number().nullable(),
maxRestartCount: z.number().nullable(),
});
// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}. Keep
// aligned with DockerContainerDetailsResult in domain/monitors/monitor.type.ts.
export const dockerContainerDetailsResponseSchema = z.object({
monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }),
stats: z.object({
aggregate: z.array(dockerContainerStatsBucketResponseSchema),
restartsInRange: z.number(),
latest: z
.object({
container: dockerContainerResponseSchema,
checkedAt: z.string(),
})
.nullable(),
}),
});
@@ -1,5 +1,5 @@
import CheckModel from "@/domain/checks/check.model.js";
import { DockerStatsBucket } from "@/domain/checks/check.type.js";
import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js";
import mongoose from "mongoose";
type DateRange = { start: Date; end: Date };
@@ -52,3 +52,43 @@ export const getDockerLatestCheck = async (monitorId: string) =>
.sort({ createdAt: -1 })
.select("containers containerSummary createdAt")
.lean();
export const getDockerContainerStats = async (
monitorId: string,
containerName: string,
dates: DateRange,
dateString: string
): Promise<DockerContainerStatsBucket[]> =>
CheckModel.aggregate<DockerContainerStatsBucket>([
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
createdAt: { $gte: dates.start, $lte: dates.end },
"containers.name": containerName,
},
},
{ $unwind: "$containers" },
{ $match: { "containers.name": containerName } },
{
$group: {
_id: { $dateToString: { format: dateString, date: "$createdAt" } },
avgCpuPct: { $avg: "$containers.cpuPct" },
avgMemoryUsedBytes: { $avg: "$containers.memoryUsedBytes" },
avgMemoryPct: { $avg: "$containers.memoryPct" },
minRestartCount: { $min: "$containers.restartCount" },
maxRestartCount: { $max: "$containers.restartCount" },
},
},
{ $sort: { _id: 1 } },
]);
export const getDockerContainerLatestCheck = async (monitorId: string, containerName: string) =>
CheckModel.findOne({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
"containers.name": containerName,
})
.sort({ createdAt: -1 })
.select("containers createdAt")
.lean();
+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 }
);
@@ -4,6 +4,7 @@ import type {
ChecksSummary,
DailyCheckBucket,
DockerChecksResult,
DockerContainerStats,
HardwareChecksResult,
PageSpeedChecksResult,
UptimeChecksResult,
@@ -40,6 +41,7 @@ export interface IChecksRepository {
dateRange: DateRange,
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
@@ -10,6 +10,7 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
DockerContainerStats,
GotTimings,
HardwareCheckStats,
} from "@/domain/checks/check.type.js";
@@ -24,7 +25,14 @@ import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@
import { CheckFilter, DateRange } from "@/types/query.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
import { getDockerLatestCheck, getDockerStats, getDockerTotalChecks, getDockerUpChecks } from "@/domain/checks/check.docker.aggregation.js";
import {
getDockerContainerLatestCheck,
getDockerContainerStats,
getDockerLatestCheck,
getDockerStats,
getDockerTotalChecks,
getDockerUpChecks,
} from "@/domain/checks/check.docker.aggregation.js";
const SERVICE_NAME = "ChecksRepository";
@@ -311,6 +319,30 @@ class MongoChecksRepository implements IChecksRepository {
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString);
};
findDockerContainerChecks = async (
monitorId: string,
containerName: string,
dateRange: DateRange
): Promise<Omit<DockerContainerStats, "restartsInRange">> => {
const dates = { start: getDateForRange(dateRange), end: new Date() };
const dateString = getDateFormat(dateRange);
const [aggregate, latestDoc] = await Promise.all([
getDockerContainerStats(monitorId, containerName, dates, dateString),
getDockerContainerLatestCheck(monitorId, containerName),
]);
const latestContainer = latestDoc?.containers?.find((c) => c.name === containerName);
return {
aggregate,
latest:
latestDoc && latestContainer
? {
container: latestContainer,
checkedAt: toDateString(latestDoc.createdAt),
}
: null,
};
};
findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
+18
View File
@@ -281,3 +281,21 @@ export interface DockerStats {
export interface DockerChecksResult extends DockerStats {
monitorType: "docker";
}
export type DockerContainerStatsBucket = {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
};
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
+63 -1
View File
@@ -8,9 +8,16 @@ import type {
GamesMap,
GroupedGeoCheckResult,
DockerDetailsResult,
DockerContainerDetailsResult,
} from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js";
import type {
UptimeChecksResult,
HardwareChecksResult,
PageSpeedChecksResult,
DockerChecksResult,
DockerContainerStatsBucket,
} from "@/domain/checks/check.type.js";
import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
@@ -31,6 +38,24 @@ const isUptimeChecksResult = (
result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult
): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType);
// Docker's RestartCount is cumulative and resets to 0 when a container is recreated, so restarts in the
// date range are recovered from bucket min/max deltas; a drop between buckets means a recreate.
const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number => {
let restarts = 0;
let prevMax: number | null = null;
for (const bucket of aggregate) {
const { minRestartCount: min, maxRestartCount: max } = bucket;
if (min === null || max === null) continue;
restarts += Math.max(0, max - min);
if (prevMax !== null) {
const delta = min - prevMax;
restarts += delta >= 0 ? delta : min + 1;
}
prevMax = max;
}
return restarts;
};
export interface IMonitorService {
// create
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
@@ -42,6 +67,12 @@ export interface IMonitorService {
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>;
getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<DockerDetailsResult>;
getDockerContainerByName(args: {
teamId: string;
monitorId: string;
containerName: string;
dateRange: DateRange;
}): Promise<DockerContainerDetailsResult>;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
@@ -323,6 +354,37 @@ export class MonitorService implements IMonitorService {
};
};
getDockerContainerByName = async ({
teamId,
monitorId,
containerName,
dateRange,
}: {
teamId: string;
monitorId: string;
containerName: string;
dateRange: DateRange;
}): Promise<DockerContainerDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "docker") {
throw new AppError({ message: `${monitor.type} monitors are not supported for docker container details`, status: 400 });
}
const { aggregate, latest } = await this.checksRepository.findDockerContainerChecks(monitor.id, containerName, dateRange);
return {
monitor,
stats: {
aggregate,
latest,
restartsInRange: computeRestartsInRange(aggregate),
},
};
};
getGeoChecksByMonitorId = async ({
teamId,
monitorId,
+6 -1
View File
@@ -1,4 +1,4 @@
import type { CheckSnapshot, DockerStats } from "@/domain/checks/check.type.js";
import type { CheckSnapshot, DockerContainerStats, DockerStats } from "@/domain/checks/check.type.js";
export type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
@@ -160,6 +160,11 @@ export interface DockerDetailsResult {
monitorStats: MonitorStats | null;
}
export interface DockerContainerDetailsResult {
monitor: Monitor;
stats: DockerContainerStats;
}
export interface PageSpeedDetailsResult {
monitorData: {
monitor: Monitor;
@@ -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", () => {
@@ -29,6 +29,7 @@ const createMonitorsRepositoryMock = () =>
const createChecksRepositoryMock = () =>
({
findByDateRangeAndMonitorId: jest.fn(),
findDockerContainerChecks: jest.fn(),
deleteByMonitorId: jest.fn(),
}) as unknown as IChecksRepository;
@@ -486,6 +487,113 @@ describe("MonitorService", () => {
});
});
describe("getDockerContainerByName", () => {
const makeBucket = (overrides: Partial<Record<string, number | string | null>> = {}) => ({
_id: "2024-01-01T00:00:00Z",
avgCpuPct: 12.5,
avgMemoryUsedBytes: 1024,
avgMemoryPct: 50,
minRestartCount: 0,
maxRestartCount: 0,
...overrides,
});
it("returns container details for a docker monitor", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const checksRepository = createChecksRepositoryMock();
const monitor = makeMonitor({ type: "docker" });
const container = { id: "abc", name: "checkmate-mongo", image: "mongo:8.0", state: "running", status: "Up 3 hours", health: "healthy" };
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({
aggregate: [makeBucket()],
latest: { container, checkedAt: "2024-01-01T00:00:00Z" },
});
const { service } = createService({ monitorsRepository, checksRepository });
const result = await service.getDockerContainerByName({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
containerName: "checkmate-mongo",
dateRange: "recent",
});
expect(checksRepository.findDockerContainerChecks).toHaveBeenCalledWith(MONITOR_ID, "checkmate-mongo", "recent");
expect(result.monitor).toMatchObject({ id: MONITOR_ID });
expect(result.stats.latest?.container).toMatchObject({ name: "checkmate-mongo" });
expect(result.stats.restartsInRange).toBe(0);
});
it("sums restarts within and between buckets", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const checksRepository = createChecksRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
(checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({
aggregate: [
makeBucket({ _id: "01", minRestartCount: 0, maxRestartCount: 2 }),
makeBucket({ _id: "02", minRestartCount: 3, maxRestartCount: 3 }),
makeBucket({ _id: "03", minRestartCount: null, maxRestartCount: null }),
makeBucket({ _id: "04", minRestartCount: 3, maxRestartCount: 4 }),
],
latest: null,
});
const { service } = createService({ monitorsRepository, checksRepository });
const result = await service.getDockerContainerByName({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
containerName: "checkmate-mongo",
dateRange: "day",
});
// 2 within bucket 01, +1 between 01 and 02, +1 within 04 (null bucket skipped)
expect(result.stats.restartsInRange).toBe(4);
});
it("counts a restart-count drop as a recreate", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const checksRepository = createChecksRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" }));
(checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({
aggregate: [
makeBucket({ _id: "01", minRestartCount: 5, maxRestartCount: 5 }),
makeBucket({ _id: "02", minRestartCount: 1, maxRestartCount: 1 }),
],
latest: null,
});
const { service } = createService({ monitorsRepository, checksRepository });
const result = await service.getDockerContainerByName({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
containerName: "checkmate-mongo",
dateRange: "day",
});
// counter dropped 5 -> 1: the recreate itself plus the 1 restart the new counter shows
expect(result.stats.restartsInRange).toBe(2);
});
it("throws 404 when monitor not found", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(null);
const { service } = createService({ monitorsRepository });
await expect(
service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: "missing", containerName: "checkmate-mongo", dateRange: "recent" })
).rejects.toThrow("Monitor with ID missing not found.");
});
it("throws 400 when monitor type is not docker", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" }));
const { service } = createService({ monitorsRepository });
await expect(
service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "checkmate-mongo", dateRange: "recent" })
).rejects.toThrow("monitors are not supported for docker container details");
});
});
describe("getPageSpeedDetailsById", () => {
it("returns pagespeed details for a pagespeed monitor", async () => {
const monitorsRepository = createMonitorsRepositoryMock();