diff --git a/client/src/Components/design-elements/Breadcrumb.tsx b/client/src/Components/design-elements/Breadcrumb.tsx
index ae3e768c2..7ce2560f6 100644
--- a/client/src/Components/design-elements/Breadcrumb.tsx
+++ b/client/src/Components/design-elements/Breadcrumb.tsx
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
+ const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
- const hasSubPage = isActionPage || isDetailsPage;
+ // page/section/:id routes such as docker/host/:monitorId
+ const isSectionDetailsPage = Boolean(
+ secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
+ );
+ const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const getSubPageLabel = (): string => {
- if (isActionPage) {
+ if (isActionPage || isSectionDetailsPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx
index c3fd089d7..63c5b0f35 100644
--- a/client/src/Components/design-elements/StatusLabel.tsx
+++ b/client/src/Components/design-elements/StatusLabel.tsx
@@ -1,12 +1,19 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
+import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
-import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
+import {
+ getDockerStatePalette,
+ getStatusPalette,
+ getValuePalette,
+} from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
+import type { DockerContainerState } from "@/Types/Check";
+import { SPACING } from "@/Utils/Theme/constants";
export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number];
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
);
};
+
+export const DockerStateLabel = ({
+ state,
+ name,
+ image,
+}: {
+ state: DockerContainerState;
+ name: string;
+ image: string;
+}) => {
+ const palette = getDockerStatePalette(state);
+ const theme = useTheme();
+
+ return (
+
+
+
+ {name}
+ {image}
+
+
+ );
+};
diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx
index 196ef2042..d746150ed 100644
--- a/client/src/Components/monitors/ControlsFilter.tsx
+++ b/client/src/Components/monitors/ControlsFilter.tsx
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
-const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
+const types = ["http", "ping", "port", "game", "grpc", "websocket"];
const typeDisplayNames: Record = {
http: "HTTP",
ping: "Ping",
port: "Port",
- docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index e7c4b9581..f8446f561 100644
--- a/client/src/Components/sidebar/Menu.tsx
+++ b/client/src/Components/sidebar/Menu.tsx
@@ -19,6 +19,7 @@ import {
Users,
Tag,
Waypoints,
+ Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
@@ -40,6 +41,11 @@ export const getMenu = (t: Function) => {
path: "infrastructure",
icon: ,
},
+ {
+ name: t("components.sidebar.menu.docker"),
+ path: "docker",
+ icon: ,
+ },
],
},
{
diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts
index f759b2957..a07b629f8 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -8,7 +8,8 @@ type TableName =
| "maintenance"
| "infrastructure"
| "logs"
- | "pagespeed";
+ | "pagespeed"
+ | "docker";
interface TableState {
rowsPerPage: number;
@@ -24,6 +25,7 @@ interface UIState {
team: TableState;
maintenance: TableState;
infrastructure: TableState;
+ docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -56,6 +58,9 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
+ docker: {
+ rowsPerPage: 5,
+ },
logs: {
rowsPerPage: 15,
},
diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx
new file mode 100644
index 000000000..5937e113e
--- /dev/null
+++ b/client/src/Pages/Docker/Details/index.tsx
@@ -0,0 +1,5 @@
+const DockerDetailsPage = () => {
+ return null;
+};
+
+export default DockerDetailsPage;
diff --git a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx
new file mode 100644
index 000000000..bf5bdc216
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx
@@ -0,0 +1,120 @@
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import { DockerStateLabel, Table } from "@/Components/design-elements";
+// Hooks
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+
+// Types
+import type { Header } from "@/Components/design-elements/Table";
+import type { DockerContainerInfo } from "@/Types/Check";
+
+// Utils
+import { formatPercentage } from "@/Utils/FormatUtils";
+
+interface DockerContainersTableProps {
+ containers: DockerContainerInfo[];
+ onRowClick?: (container: DockerContainerInfo) => void;
+}
+
+export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const navigate = useNavigate();
+
+ const getHeaders = () => {
+ // const renderSortIcon = (isActive: boolean) => (
+ //
+ // {isActive ? (
+ // sortOrder === "asc" ? (
+ //
+ // ) : (
+ //
+ // )
+ // ) : null}
+ //
+ // );
+ const headers: Header[] = [
+ {
+ id: "container",
+ content: t("pages.docker.host.table.headers.container"),
+ render: (row) => {
+ return (
+
+ );
+ },
+ },
+ {
+ id: "status",
+ content: t("common.table.headers.status"),
+ render: (row) => {
+ return {row.state};
+ },
+ },
+ {
+ id: "health",
+ content: t("pages.docker.host.table.headers.health"),
+ render: (row) => {
+ return {row.health};
+ },
+ },
+ {
+ id: "cpu",
+ content: t("pages.docker.host.table.headers.cpu"),
+ render: (row) => {
+ return {formatPercentage(row.cpuPct ?? 0)};
+ },
+ },
+ {
+ id: "memory",
+ content: t("pages.docker.host.table.headers.memory"),
+ render: (row) => {
+ return {formatPercentage(row.memoryPct ?? 0)};
+ },
+ },
+ {
+ id: "restarts",
+ content: t("pages.docker.host.table.headers.restarts"),
+ render: (row) => {
+ return {row.restartCount};
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+
+ console.log(JSON.stringify(containers, null, 2));
+ return (
+
+ {
+ navigate(`/docker/container/${row.id}`);
+ }}
+ // getRowSx={(row) => ({
+ // backgroundColor: isRowSelected(row.id)
+ // ? theme.palette.action.selected
+ // : "inherit",
+ // })}
+ />
+
+ );
+};
diff --git a/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
new file mode 100644
index 000000000..1f65836b0
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
@@ -0,0 +1,47 @@
+import Stack from "@mui/material/Stack";
+import { StatBox } from "@/Components/design-elements";
+
+// Types
+import type { DockerStats } from "@/Types/Monitor";
+
+// Hooks
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+// Utils
+
+export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ if (!stats) return null;
+
+ const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/HostDetails/index.tsx b/client/src/Pages/Docker/HostDetails/index.tsx
new file mode 100644
index 000000000..4cd2d3903
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/index.tsx
@@ -0,0 +1,52 @@
+import { BasePage } from "@/Components/design-elements";
+import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
+import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
+
+// Types
+import type { DockerDetailsResponse } from "@/Types/Monitor";
+
+// Hooks
+import { useGet } from "@/Hooks/UseApi";
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+import { useParams } from "react-router-dom";
+import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
+
+const DockerHostDetailsPage = () => {
+ const { monitorId } = useParams<{ monitorId: string }>();
+ const isAdmin = useIsAdmin();
+
+ // The containers list renders the latest check only, so no date range is
+ // offered; the endpoint's dateRange param only shapes the aggregate buckets.
+ const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
+
+ const { data: monitorDetailsData, refetch: refetchMonitor } =
+ useGet(
+ monitorDetailsUrl,
+ {},
+ { refreshInterval: 10000, keepPreviousData: true }
+ );
+
+ const monitor = monitorDetailsData?.monitor;
+ const monitorStats = monitorDetailsData?.monitorStats ?? null;
+ const stats = monitorDetailsData?.stats;
+ const containers = stats?.latest?.containers ?? [];
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default DockerHostDetailsPage;
diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
new file mode 100644
index 000000000..7b35e6bfe
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
@@ -0,0 +1,347 @@
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import {
+ Table,
+ Pagination,
+ StatusLabel,
+ ColoredLabel,
+} from "@/Components/design-elements";
+import { SPACING } from "@/Utils/Theme/constants";
+import type { Header } from "@/Components/design-elements/Table";
+import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
+import { ArrowUp, ArrowDown } from "lucide-react";
+
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+import { usePost } from "@/Hooks/UseApi";
+
+import type { Monitor } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import type { DockerContainerSummary } from "@/Types/Check";
+import { Checkbox } from "@/Components/inputs";
+
+const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
+ const recentChecks = monitor.recentChecks ?? [];
+ return recentChecks[recentChecks.length - 1]?.containerSummary;
+};
+
+interface DockerMonitorsTableProps {
+ monitors: Monitor[];
+ tags: Tag[];
+ refetch: () => void;
+ setSelectedMonitor: (monitor: Monitor | null) => void;
+ sortField: string;
+ setSortField: (field: string) => void;
+ sortOrder: "asc" | "desc";
+ setSortOrder: (order: "asc" | "desc") => void;
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+ selectedRows?: string[];
+ onSelectionChange?: (selected: string[]) => void;
+}
+
+export const DockerMonitorsTable = ({
+ monitors,
+ tags,
+ refetch,
+ setSelectedMonitor,
+ sortField,
+ setSortField,
+ sortOrder,
+ setSortOrder,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+ selectedRows = [],
+ onSelectionChange,
+}: DockerMonitorsTableProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const navigate = useNavigate();
+ const { post } = usePost, Monitor>();
+
+ const selectedSet = new Set(selectedRows);
+ const isAllSelected =
+ monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
+ const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
+
+ const handleSelectAll = (checked: boolean) => {
+ onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
+ };
+
+ const handleSelectRow = (id: string, checked: boolean) => {
+ if (!onSelectionChange) return;
+ onSelectionChange(
+ checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
+ );
+ };
+
+ const isRowSelected = (id: string) => selectedSet.has(id);
+
+ const handlePageChange = (
+ _e: React.MouseEvent | null,
+ newPage: number
+ ) => {
+ setPage(newPage);
+ };
+
+ const handleRowsPerPageChange = (
+ e: React.ChangeEvent
+ ) => {
+ const value = Number(e.target.value);
+ setPage(0);
+ setRowsPerPage(value);
+ };
+
+ const handleSort = (e: React.MouseEvent, field: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (sortField === field) {
+ const newOrder = sortOrder === "asc" ? "desc" : "asc";
+ setSortOrder(newOrder);
+ } else {
+ setSortField(field);
+ setSortOrder("asc");
+ }
+ refetch();
+ };
+
+ const getActions = (monitor: Monitor): ActionMenuItem[] => {
+ return [
+ {
+ id: 2,
+ label: t("pages.common.monitors.actions.details"),
+ action: () => {
+ navigate(`${monitor.id}`);
+ },
+ },
+ {
+ id: 3,
+ label: t("pages.common.monitors.actions.incidents"),
+ action: () => {
+ navigate(`/incidents?monitorId=${monitor.id}`);
+ },
+ },
+ {
+ id: 4,
+ label: t("pages.common.monitors.actions.configure"),
+ action: () => {
+ navigate(`/docker/configure/${monitor.id}`);
+ },
+ },
+ {
+ id: 6,
+ label:
+ monitor.isActive === false
+ ? t("common.buttons.resume")
+ : t("common.buttons.pause"),
+ action: async () => {
+ await post(`/monitors/pause/${monitor.id}`, {});
+ refetch();
+ },
+ closeMenu: true,
+ },
+ {
+ id: 7,
+ label: (
+
+ {t("common.buttons.delete")}
+
+ ),
+ action: () => {
+ setSelectedMonitor(monitor);
+ },
+ closeMenu: true,
+ },
+ ];
+ };
+
+ const getHeaders = () => {
+ const renderSortIcon = (isActive: boolean) => (
+
+ {isActive ? (
+ sortOrder === "asc" ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ );
+ const headers: Header[] = [
+ {
+ id: "selection",
+ content: (
+ handleSelectAll(e.target.checked)}
+ />
+ ),
+ hideMobileLabel: true,
+ render: (row) => (
+ {
+ handleSelectRow(row.id, e.target.checked);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ),
+ onClick: (e) => e?.stopPropagation(),
+ },
+ {
+ id: "name",
+ align: "left",
+ mobileLabel: t("pages.docker.table.headers.host"),
+ content: (
+ handleSort(e, "name")}
+ sx={{ cursor: "pointer" }}
+ >
+ {t("pages.docker.table.headers.host")}
+ {renderSortIcon(sortField === "name")}
+
+ ),
+ render: (row) => {
+ return (
+
+ {row.name}
+
+ {row.url}
+
+
+ );
+ },
+ },
+ {
+ id: "status",
+ mobileLabel: t("common.table.headers.status"),
+ content: (
+ handleSort(e, "status")}
+ sx={{ cursor: "pointer" }}
+ >
+
+ {t("common.table.headers.status")}
+ {renderSortIcon(sortField === "status")}
+
+ ),
+ render: (row) => {
+ return ;
+ },
+ },
+ {
+ id: "containers",
+ content: t("pages.docker.table.headers.containers"),
+ render: (row) => {
+ const summary = summaryOf(row);
+ return (
+
+ {summary
+ ? t("pages.docker.table.containersRunning", {
+ running: summary.running,
+ total: summary.total,
+ })
+ : "—"}
+
+ );
+ },
+ },
+ {
+ id: "tags",
+ content: t("common.table.headers.tags"),
+ render: (row) => {
+ if (row.tags.length === 0) return "-";
+ return (
+
+ {row.tags.map((tagId) => {
+ const fullTag = tags.find((t) => t.id === tagId);
+ if (!fullTag) return null;
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
+
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+ return (
+
+ {
+ navigate(`/docker/host/${row.id}`);
+ }}
+ getRowSx={(row) => ({
+ backgroundColor: isRowSelected(row.id)
+ ? theme.palette.action.selected
+ : "inherit",
+ })}
+ />
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/Monitors/index.tsx b/client/src/Pages/Docker/Monitors/index.tsx
new file mode 100644
index 000000000..96d13abbf
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/index.tsx
@@ -0,0 +1,44 @@
+import { MonitorListPage } from "@/Components/monitors";
+import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
+
+const DockerMonitorsPage = () => {
+ const c = useMonitorListController({
+ types: ["docker"],
+ checksLimit: 25,
+ refreshInterval: 5000,
+ rowsPerPageTable: "docker",
+ rowsPerPageDefault: 10,
+ });
+
+ return (
+
+
+
+ );
+};
+
+export default DockerMonitorsPage;
diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx
index 968b468c2..e7735fcd0 100644
--- a/client/src/Routes/index.tsx
+++ b/client/src/Routes/index.tsx
@@ -20,6 +20,11 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
import Infrastructure from "@/Pages/Infrastructure/Monitors";
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";
+
// Checks
import Checks from "@/Pages/Checks";
@@ -139,6 +144,26 @@ const Routes = () => {
path="infrastructure/:monitorId"
element={}
/>
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
}
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index 78e970f04..6e5ca29f8 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -119,6 +119,46 @@ export interface CheckTimings {
};
}
+// Mirrors DockerContainerSummary in server/src/types/network.ts.
+export interface DockerContainerSummary {
+ total: number;
+ running: number;
+ stopped: number;
+ unhealthy: number;
+}
+
+// Mirrors DockerContainerStates in server/src/types/network.ts.
+export const DockerContainerStates = [
+ "created",
+ "running",
+ "paused",
+ "restarting",
+ "removing",
+ "exited",
+ "dead",
+] 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 interface DockerContainerInfo {
+ id: string;
+ name: string;
+ image: string;
+ state: DockerContainerState;
+ status: string;
+ health: DockerHealthStatus;
+ cpuPct?: number; // fraction of one core; exceeds 1 when using multiple cores
+ memoryUsedBytes?: number;
+ memoryLimitBytes?: number;
+ memoryPct?: number; // 0-1 fraction of the memory limit
+ restartCount?: number;
+ startedAt?: string;
+}
+
export interface Check {
id: string;
metadata: CheckMetadata;
@@ -133,6 +173,7 @@ export interface Check {
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
+ containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
@@ -287,6 +328,7 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
+ containerSummary?: DockerContainerSummary;
};
export interface HasResponseTime {
responseTime: number;
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index a5ed14c01..8b83a5a72 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -3,6 +3,8 @@ import type {
CheckSnapshot,
GroupedUptimeCheck,
DailyCheckBucket,
+ DockerContainerInfo,
+ DockerContainerSummary,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
@@ -30,7 +32,6 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const SelectableMonitorTypes = [
"http",
"ping",
- "docker",
"port",
"game",
"grpc",
@@ -269,6 +270,41 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
+// Mirrors DockerStatsBucket in server/src/domain/checks/check.type.ts.
+export interface DockerStatsBucket {
+ _id: string;
+ avgResponseTime: number | null;
+ upCount: number;
+ totalCount: number;
+ avgRunning: number | null;
+ avgTotal: number | null;
+ avgUnhealthy: number | null;
+}
+
+// Mirrors DockerStats in server/src/domain/checks/check.type.ts.
+export interface DockerStats {
+ aggregateData: {
+ totalChecks: number;
+ };
+ upChecks: {
+ totalChecks: number;
+ };
+ aggregate: DockerStatsBucket[];
+ latest: {
+ containers: DockerContainerInfo[];
+ summary?: DockerContainerSummary;
+ checkedAt: string;
+ } | 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 Game {
name: string;
options?: {
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index 401771f94..ef48c2223 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -1,6 +1,7 @@
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";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record = {
@@ -12,7 +13,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
- docker: "uptime",
+ docker: "docker",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
@@ -41,6 +42,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value];
};
+export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
+ const paletteMap: Record = {
+ paused: "warning",
+ created: "warning",
+ running: "success",
+ restarting: "warning",
+ removing: "warning",
+ exited: "error",
+ dead: "error",
+ };
+ return paletteMap[state];
+};
+
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === "up") {
return theme.palette.success.light;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index c6a2da621..2e6cdb2bc 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -203,6 +203,7 @@
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
+ "docker": "Docker",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
@@ -924,6 +925,41 @@
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
}
},
+ "docker": {
+ "header": {
+ "title": "Docker monitors",
+ "description": "Monitor your Docker hosts and every container running on them."
+ },
+ "fallback": {
+ "title": "No Docker monitors yet",
+ "description": "Add a Docker host to monitor its containers.",
+ "actionButton": "Create a monitor"
+ },
+ "table": {
+ "headers": {
+ "host": "Host",
+ "containers": "Containers"
+ },
+ "containersRunning": "{{running}} of {{total}} running"
+ },
+ "host": {
+ "statBoxes": {
+ "containers": "Containers",
+ "running": "Running",
+ "stopped": "Stopped",
+ "unhealthy": "Unhealthy"
+ },
+ "table": {
+ "headers": {
+ "container": "Containers",
+ "health": "Health",
+ "cpu": "CPU",
+ "memory": "Memory",
+ "restarts": "Restarts"
+ }
+ }
+ }
+ },
"infrastructure": {
"charts": {
"labels": {
diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts
index a24ad2c18..152729f10 100644
--- a/server/src/service/network/DockerProvider.ts
+++ b/server/src/service/network/DockerProvider.ts
@@ -100,7 +100,7 @@ export class DockerProvider implements IStatusProvider {
const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage ?? 0);
const onlineCpus = stats.cpu_stats.online_cpus ?? stats.cpu_stats.cpu_usage.percpu_usage?.length ?? 1;
if (!(systemDelta > 0) || cpuDelta < 0) return 0;
- return (cpuDelta / systemDelta) * onlineCpus * 100;
+ return (cpuDelta / systemDelta) * onlineCpus;
}
private computeMemory(stats: Dockerode.ContainerStats): Pick {
@@ -111,7 +111,7 @@ export class DockerProvider implements IStatusProvider {
return {
memoryUsedBytes,
memoryLimitBytes,
- memoryPct: memoryLimitBytes > 0 ? (memoryUsedBytes / memoryLimitBytes) * 100 : 0,
+ memoryPct: memoryLimitBytes > 0 ? memoryUsedBytes / memoryLimitBytes : 0,
};
}
diff --git a/server/src/types/network.ts b/server/src/types/network.ts
index 62f359b46..25ee220ac 100644
--- a/server/src/types/network.ts
+++ b/server/src/types/network.ts
@@ -117,10 +117,10 @@ export interface DockerContainerInfo {
state: DockerContainerState;
status: string;
health: DockerHealthStatus;
- cpuPct?: number;
+ cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores
memoryUsedBytes?: number;
memoryLimitBytes?: number;
- memoryPct?: number;
+ memoryPct?: number; // 0-1 fraction of the memory limit
restartCount?: number;
startedAt?: string; // ISO date
}
diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts
index 4aa1431dc..74ca2d8db 100644
--- a/server/test/unit/providers/network/dockerProvider.test.ts
+++ b/server/test/unit/providers/network/dockerProvider.test.ts
@@ -324,10 +324,10 @@ describe("DockerProvider", () => {
const result = await provider.handle(makeMonitor());
const container = result.payload?.containers[0];
- expect(container?.cpuPct).toBeCloseTo(80);
+ expect(container?.cpuPct).toBeCloseTo(0.8);
expect(container?.memoryUsedBytes).toBe(200 * 1024 * 1024);
expect(container?.memoryLimitBytes).toBe(1024 * 1024 * 1024);
- expect(container?.memoryPct).toBeCloseTo(19.53125);
+ expect(container?.memoryPct).toBeCloseTo(0.1953125);
});
it("falls back to the cgroup v1 cache field for page cache", async () => {
@@ -351,7 +351,7 @@ describe("DockerProvider", () => {
const result = await provider.handle(makeMonitor());
- expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(40);
+ expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(0.4);
});
it("returns 0 cpu when the system delta is not positive", async () => {