implement docker host details page
This commit is contained in:
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)
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user