From ca896fa22f5fa2bb6d8993fd0ff14770d8e12c9e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 31 Aug 2026 12:02:40 -0700 Subject: [PATCH] implement docker host details page --- .../Components/design-elements/Breadcrumb.tsx | 9 +- .../design-elements/StatusLabel.tsx | 42 +++++- client/src/Pages/Docker/Details/index.tsx | 5 - .../Components/DockerContainersTable.tsx | 120 ++++++++++++++++++ .../HostDetails/Components/StatBoxes.tsx | 48 +++++++ client/src/Pages/Docker/HostDetails/index.tsx | 52 ++++++++ .../Components/DockerMonitorsTable.tsx | 2 +- client/src/Routes/index.tsx | 7 +- client/src/Types/Check.ts | 32 +++++ client/src/Types/Monitor.ts | 37 ++++++ client/src/Utils/MonitorUtils.ts | 15 +++ server/src/service/network/DockerProvider.ts | 4 +- server/src/types/network.ts | 4 +- .../providers/network/dockerProvider.test.ts | 6 +- 14 files changed, 366 insertions(+), 17 deletions(-) delete mode 100644 client/src/Pages/Docker/Details/index.tsx create mode 100644 client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx create mode 100644 client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx create mode 100644 client/src/Pages/Docker/HostDetails/index.tsx 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 () => {