Merge pull request #3912 from bluewave-labs/feat/docker-container-details
feat: docker container details
This commit is contained in:
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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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";
|
||||
@@ -157,7 +157,7 @@ const Routes = () => {
|
||||
element={<CreateMonitor />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/:monitorId"
|
||||
path="docker/host/:monitorId/container/:containerName"
|
||||
element={<DockerDetails />}
|
||||
/>
|
||||
<Route
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user