containers detail charts

This commit is contained in:
Alex Holliday committed 2026-09-01 11:23:03 -07:00
1 parent f85ed902e4
commit 56153b38a5
16 files changed
+451 -140

No files matched your search

@@ -10,6 +10,7 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode;
title: string;
rightTitle?: string;
width?: number | string;
maxWidth?: number | string;
flexBasis?: number | string;
@@ -20,6 +21,7 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
rightTitle,
width = "100%",
maxWidth = "100%",
flexBasis = "0%",
@@ -47,13 +49,27 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
<Stack
direction="row"
justifyContent={"space-between"}
>
{title}
</Typography>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -0,0 +1,113 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -0,0 +1,70 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
export const HistogramInfrastructure = ({
dateRange,
Whitespace-only changes.
@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
import type { Monitor } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
export const DockerContainerStatusBoxes = ({
monitor,
container,
}: {
monitor: Monitor | undefined;
container: DockerContainerInfo | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!container) return null;
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
subtitle={health}
/>
</Stack>
);
};
@@ -0,0 +1,3 @@
export const TabLogs = () => {
return "This is the logs tab";
};
@@ -0,0 +1,62 @@
// Components
import Grid from "@mui/material/Grid";
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
// Types
import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
title: t("common.charts.labels.cpuUsage"),
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
},
];
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Grid
container
spacing={LAYOUT.MD}
>
{getChartConfigs(theme, t, stats).map((config) => {
return (
<Grid
key={config.key}
size={isSmall ? 12 : 6}
>
<HistogramDockerContainer
title={config.title}
rightTitle={config.rightTitle}
stats={stats}
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
/>
</Grid>
);
})}
</Grid>
);
};
+72 -2
View File
@@ -1,5 +1,75 @@
const DockerDetailsPage = () => {
return null;
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/Details/TabOverview";
import { TabLogs } from "@/Pages/Docker/Details/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { DockerContainerResponse } from "@/Types/Monitor";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
const TAB_MAP = {
overview: 0,
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const { monitorId, containerName } = useParams<{
monitorId: string;
containerName: string;
}>();
const { t } = useTranslation();
const monitorDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}`
: null;
const { data: containerData, refetch: refetchContainer } =
useGet<DockerContainerResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = containerData?.monitor;
if (!monitor) return null;
const stats = containerData.stats;
const container = stats?.latest?.container;
if (!container) return null;
const { state, name, image } = container;
return (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes
container={container}
monitor={monitor}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && <TabLogs />}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -15,11 +15,15 @@ import type { DockerContainerInfo } from "@/Types/Check";
import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
containers: DockerContainerInfo[];
onRowClick?: (container: DockerContainerInfo) => void;
}
export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => {
export const DockerContainersTable = ({
monitorId,
containers,
}: DockerContainersTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -64,7 +68,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
},
{
id: "health",
content: t("pages.docker.host.table.headers.health"),
content: t("common.labels.health"),
render: (row) => {
return <Typography>{row.health}</Typography>;
},
@@ -85,7 +89,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
},
{
id: "restarts",
content: t("pages.docker.host.table.headers.restarts"),
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
@@ -100,14 +104,13 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps
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}`);
navigate(`/docker/host/${monitorId}/container/${row.name}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
@@ -44,7 +44,10 @@ const DockerHostDetailsPage = () => {
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable containers={containers} />
<DockerContainersTable
monitorId={monitorId}
containers={containers}
/>
</BasePage>
);
};
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("pages.infrastructure.charts.labels.memory"),
title: t("common.charts.labels.memoryUsage"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null,
},
{
title: t("pages.infrastructure.charts.labels.cpu"),
title: t("common.charts.labels.cpuUsage"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
+1 -1
View File
@@ -157,7 +157,7 @@ const Routes = () => {
element={<CreateMonitor />}
/>
<Route
path="docker/:monitorId"
path="docker/host/:monitorId/container/:containerName"
element={<DockerDetails />}
/>
<Route
+21 -4
View File
@@ -270,7 +270,6 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
// Mirrors DockerStatsBucket in server/src/domain/checks/check.type.ts.
export interface DockerStatsBucket {
_id: string;
avgResponseTime: number | null;
@@ -281,7 +280,6 @@ export interface DockerStatsBucket {
avgUnhealthy: number | null;
}
// Mirrors DockerStats in server/src/domain/checks/check.type.ts.
export interface DockerStats {
aggregateData: {
totalChecks: number;
@@ -297,13 +295,32 @@ export interface DockerStats {
} | 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 DockerContainerStatsBucket {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
}
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
export interface DockerContainerResponse {
monitor: Monitor;
stats: DockerContainerStats;
}
export interface Game {
name: string;
+18 -6
View File
@@ -66,9 +66,11 @@
"charts": {
"labels": {
"averageResponseTime": "Average response time",
"cpuUsage": "CPU usage",
"downtime": "Downtime",
"high": "High",
"low": "Low",
"memoryUsage": "Memory usage",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
@@ -102,7 +104,11 @@
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time"
"responseTime": "Response time",
"state": "State",
"uptime": "Uptime",
"health": "Health",
"restarts": "Restarts"
},
"form": {
"autocomplete": {
@@ -942,6 +948,16 @@
},
"containersRunning": "{{running}} of {{total}} running"
},
"container": {
"header": {
"title": "Container details",
"description": "State, resource usage, and logs for this container."
},
"tabs": {
"overview": "Overview",
"logs": "Logs"
}
},
"host": {
"statBoxes": {
"containers": "Containers",
@@ -952,10 +968,8 @@
"table": {
"headers": {
"container": "Containers",
"health": "Health",
"cpu": "CPU",
"memory": "Memory",
"restarts": "Restarts"
"memory": "Memory"
}
}
}
@@ -963,9 +977,7 @@
"infrastructure": {
"charts": {
"labels": {
"cpu": "CPU usage",
"disk": "Disk usage",
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",