implement docker host details page

This commit is contained in:
Alex Holliday committed 2026-08-31 12:02:40 -07:00
1 parent a7ee90e1ee
commit ca896fa22f
14 files changed
+366 -17

No files matched your search

@@ -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");
@@ -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 }) =
</BaseBox>
);
};
export const DockerStateLabel = ({
state,
name,
image,
}: {
state: DockerContainerState;
name: string;
image: string;
}) => {
const palette = getDockerStatePalette(state);
const theme = useTheme();
return (
<Stack
direction="row"
gap={SPACING.LG}
alignItems={"center"}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Stack>
<Typography>{name}</Typography>
<Typography variant="body2">{image}</Typography>
</Stack>
</Stack>
);
};
@@ -1,5 +0,0 @@
const DockerDetails = () => {
return null;
};
export default DockerDetails;
@@ -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) => (
// <Box
// width={16}
// display="inline-flex"
// justifyContent="center"
// >
// {isActive ? (
// sortOrder === "asc" ? (
// <ArrowUp size={16} />
// ) : (
// <ArrowDown size={16} />
// )
// ) : null}
// </Box>
// );
const headers: Header<DockerContainerInfo>[] = [
{
id: "container",
content: "Containers",
render: (row) => {
return (
<DockerStateLabel
state={row.state}
name={row.name}
image={row.image}
/>
);
},
},
{
id: "status",
content: "Status",
render: (row) => {
return <Typography>{row.state}</Typography>;
},
},
{
id: "health",
content: "Health",
render: (row) => {
return <Typography>{row.health}</Typography>;
},
},
{
id: "cpu",
content: "CPU",
render: (row) => {
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
},
},
{
id: "memory",
content: "Memory",
render: (row) => {
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
},
},
{
id: "restarts",
content: "Restarts",
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
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}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
// ? theme.palette.action.selected
// : "inherit",
// })}
/>
</Box>
);
};
@@ -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 (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={"Containers"}
subtitle={String(total)}
/>
<StatBox
title={"Running"}
subtitle={String(running)}
/>
<StatBox
title={"Stopped"}
subtitle={String(stopped)}
/>
<StatBox
title={"Unhealthy"}
subtitle={String(unhealthy)}
/>
</Stack>
);
};
@@ -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<DockerDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = monitorDetailsData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const stats = monitorDetailsData?.stats;
const containers = stats?.latest?.containers ?? [];
return (
<BasePage>
<HeaderMonitorControls
path="docker"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetchMonitor}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable containers={containers} />
</BasePage>
);
};
export default DockerHostDetailsPage;
@@ -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)
+6 -1
View File
@@ -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={<DockerDetails />}
/>
<Route
path="docker/host/:monitorId"
element={<DockerHostDetails />}
/>
<Route
path="checks/:monitorId?"
element={<Checks />}
+32
View File
@@ -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;
+37
View File
@@ -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?: {
+15
View File
@@ -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<MonitorType, string> = {
@@ -41,6 +43,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value];
};
export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
const paletteMap: Record<DockerContainerState, PaletteKey> = {
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;
+2 -2
View File
@@ -100,7 +100,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
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<DockerContainerInfo, "memoryUsedBytes" | "memoryLimitBytes" | "memoryPct"> {
@@ -111,7 +111,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
return {
memoryUsedBytes,
memoryLimitBytes,
memoryPct: memoryLimitBytes > 0 ? (memoryUsedBytes / memoryLimitBytes) * 100 : 0,
memoryPct: memoryLimitBytes > 0 ? memoryUsedBytes / memoryLimitBytes : 0,
};
}
+2 -2
View File
@@ -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
}
@@ -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 () => {