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/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx
deleted file mode 100644
index e90554b97..000000000
--- a/client/src/Pages/Docker/Details/index.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const DockerDetails = () => {
- return null;
-};
-
-export default DockerDetails;
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..66ccb81e7
--- /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: "Containers",
+ render: (row) => {
+ return (
+
+ );
+ },
+ },
+ {
+ id: "status",
+ content: "Status",
+ render: (row) => {
+ return {row.state};
+ },
+ },
+ {
+ id: "health",
+ content: "Health",
+ render: (row) => {
+ return {row.health};
+ },
+ },
+ {
+ id: "cpu",
+ content: "CPU",
+ render: (row) => {
+ return {formatPercentage(row.cpuPct ?? 0)};
+ },
+ },
+ {
+ id: "memory",
+ content: "Memory",
+ render: (row) => {
+ return {formatPercentage(row.memoryPct ?? 0)};
+ },
+ },
+ {
+ id: "restarts",
+ content: "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..4116115ae
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
@@ -0,0 +1,48 @@
+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
+import prettyBytes from "pretty-bytes";
+
+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
index 33fc65706..7b35e6bfe 100644
--- a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
+++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
@@ -325,7 +325,7 @@ export const DockerMonitorsTable = ({
headers={headers}
data={monitors}
onRowClick={(row) => {
- navigate(`/docker/${row.id}`);
+ navigate(`/docker/host/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx
index 04eb68bdd..b073c08bf 100644
--- a/client/src/Routes/index.tsx
+++ b/client/src/Routes/index.tsx
@@ -22,7 +22,8 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details";
// Docker
import Docker from "@/Pages/Docker/Monitors";
-import DockerDetails from "@/Pages/Docker/Details";
+import DockerHostDetails from "@/Pages/Docker/HostDetails";
+import DockerDetails from "../../..";
// Checks
import Checks from "@/Pages/Checks";
@@ -159,6 +160,10 @@ const Routes = () => {
path="docker/:monitorId"
element={}
/>
+ }
+ />
}
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index f6f84102a..6e5ca29f8 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -127,6 +127,38 @@ export interface DockerContainerSummary {
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;
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index 759c633ec..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";
@@ -268,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 ebdd0e839..638b5539a 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -1,6 +1,8 @@
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 { Doc } from "zod/v4/core";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record = {
@@ -41,6 +43,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/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 () => {