diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 10bf888d9..8b1378917 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
\ No newline at end of file
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 3e601b453..498858e16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,7 +109,7 @@ client/src/
- **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
-- **i18n**: i18next + react-i18next (translations via PoEditor)
+- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
---
diff --git a/README.md b/README.md
index bb5185378..de6068fb9 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,7 @@
[](https://www.pikapods.com/pods?run=checkmate)
-
-
+
@@ -212,7 +211,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Contributing
-We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
+We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
diff --git a/client/package-lock.json b/client/package-lock.json
index 6cee968e3..f6adb320d 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11,6 +11,7 @@
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
+ "@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
@@ -92,6 +93,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -407,6 +409,7 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -444,6 +447,7 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -950,6 +954,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@fontsource-variable/geist": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
+ "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"license": "BSD-3-Clause"
@@ -1223,6 +1236,7 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1327,6 +1341,7 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2371,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2386,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2476,6 +2493,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2756,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3112,6 +3131,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3555,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3954,6 +3975,7 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4766,6 +4788,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5577,6 +5600,7 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6358,6 +6382,7 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6369,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6572,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6722,6 +6749,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7563,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7700,6 +7729,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/client/package.json b/client/package.json
index ce2e481ba..16f06aced 100644
--- a/client/package.json
+++ b/client/package.json
@@ -12,7 +12,8 @@
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
- "format-check": "prettier --check ."
+ "format-check": "prettier --check .",
+ "compile": "tsc -b"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
@@ -21,6 +22,7 @@
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
+ "@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
diff --git a/client/src/Components/actions-menu/index.tsx b/client/src/Components/actions-menu/index.tsx
index 3b2f3688a..2eca19cee 100644
--- a/client/src/Components/actions-menu/index.tsx
+++ b/client/src/Components/actions-menu/index.tsx
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
+import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = {
id: number | string;
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
+ const theme = useTheme();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
-
+
void;
+ dateRange: DateRange;
+ setDateRange: (dateRange: DateRange) => void;
}
export const HeaderTimeRange = ({
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent,
- newValue: string | null
+ newValue: DateRange | null
) => {
if (newValue !== null) {
setDateRange(newValue);
diff --git a/client/src/Components/design-elements/BaseBox.tsx b/client/src/Components/design-elements/BaseBox.tsx
index 580d15dcf..d5af820e6 100644
--- a/client/src/Components/design-elements/BaseBox.tsx
+++ b/client/src/Components/design-elements/BaseBox.tsx
@@ -1,10 +1,12 @@
import Box, { type BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
+import { forwardRef } from "react";
-export const BaseBox = ({ sx, ...rest }: BoxProps) => {
+export const BaseBox = forwardRef(({ sx, ...rest }, ref) => {
const theme = useTheme();
return (
{
}}
/>
);
-};
+});
+
+BaseBox.displayName = "BaseBox";
diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx
index 46abe5d39..e5233eb0f 100644
--- a/client/src/Components/design-elements/BaseChart.tsx
+++ b/client/src/Components/design-elements/BaseChart.tsx
@@ -10,8 +10,10 @@ 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;
padding?: number | string | ResponsiveStyleValue;
onClick?: () => void;
}>;
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
+ rightTitle,
width = "100%",
maxWidth = "100%",
+ flexBasis = "0%",
padding,
onClick,
}: BaseChartProps) => {
@@ -32,6 +36,7 @@ export const BaseChart = ({
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
+ flexBasis,
width: width,
maxWidth: maxWidth,
...(onClick && {
@@ -44,13 +49,27 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
-
- {title}
-
+
+ {title}
+
+ {rightTitle && (
+
+ {rightTitle}
+
+ )}
+
{children}
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/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx
index 4130f67a7..3c0d75dbe 100644
--- a/client/src/Components/design-elements/Gauge.tsx
+++ b/client/src/Components/design-elements/Gauge.tsx
@@ -39,9 +39,9 @@ export const Gauge = forwardRef(function Gauge(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
- strokeLength: (progress / 100) * (2 * Math.PI * radius),
+ strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
}),
- [radius, strokeWidth, progress]
+ [radius, strokeWidth, progressWithinRange]
);
const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef(function Gauge(
}, 100);
return () => clearTimeout(timer);
- }, [progress, circumference, strokeLength]);
+ }, [circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,6 +123,7 @@ export const DetailGauge = ({
lowerLabel,
lowerValue,
maxWidth = 225,
+ flexBasis = "0%",
}: {
title: string;
progress: number;
@@ -131,6 +132,7 @@ export const DetailGauge = ({
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
+ flexBasis?: number | string;
}) => {
const theme = useTheme();
return (
@@ -138,6 +140,7 @@ export const DetailGauge = ({
icon={null}
title={title}
maxWidth={maxWidth}
+ flexBasis={flexBasis}
>
{
+const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
return (
{
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
- backgroundColor: theme.palette.error.main,
- color: theme.palette.error.contrastText,
+ backgroundColor: theme.palette.error.dark,
+ color: "#FFFFFF",
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
diff --git a/client/src/Components/design-elements/PageHeader.tsx b/client/src/Components/design-elements/PageHeader.tsx
index 84b0f61c6..5bf00c151 100644
--- a/client/src/Components/design-elements/PageHeader.tsx
+++ b/client/src/Components/design-elements/PageHeader.tsx
@@ -6,6 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -109,7 +110,7 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
- maxWidth: 640,
+ maxWidth: "108ch",
}}
>
{description}
@@ -142,7 +143,7 @@ export const PageHeader = ({
alignItems: "center",
justifyContent: "center",
width: 36,
- height: 32,
+ height: INPUT_BASE_HEIGHT,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
diff --git a/client/src/Components/design-elements/SkeletonCard.tsx b/client/src/Components/design-elements/SkeletonCard.tsx
index 05f0c462a..add7a69eb 100644
--- a/client/src/Components/design-elements/SkeletonCard.tsx
+++ b/client/src/Components/design-elements/SkeletonCard.tsx
@@ -1,4 +1,5 @@
import { Box, Stack, useTheme } from "@mui/material";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
- height: 34,
+ height: INPUT_BASE_HEIGHT,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx
index 954120fe5..453a83687 100644
--- a/client/src/Components/design-elements/StatBox.tsx
+++ b/client/src/Components/design-elements/StatBox.tsx
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
-import { lighten } from "@mui/material/styles";
+import { darken, lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
+ interactive?: boolean;
}>;
-export const GradientBox = ({ children, palette, sx }: GradientBox) => {
+export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper;
- const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
+ const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperEnd = paper;
const bg = palette
- ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
+ ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return (
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
+ ...(interactive && {
+ cursor: "pointer",
+ "&:hover": {
+ backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
+ },
+ }),
...sx,
}}
>
@@ -62,10 +69,8 @@ export const StatBox = ({
return (
diff --git a/client/src/Components/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx
index 017873eb9..a94bd0007 100644
--- a/client/src/Components/design-elements/StatusBox.tsx
+++ b/client/src/Components/design-elements/StatusBox.tsx
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
-import { useTheme } from "@mui/material/styles";
+import { alpha, useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
sx={{
pointerEvents: "none",
backgroundImage: `
- linear-gradient(${theme.palette.divider} 1px, transparent 1px),
- linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
+ linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
+ linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
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/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx
index a02f9be8f..8bf2d46f3 100644
--- a/client/src/Components/design-elements/Table.tsx
+++ b/client/src/Components/design-elements/Table.tsx
@@ -52,6 +52,7 @@ type DataTableProps =
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps;
+ isRowSelected?: (row: T) => boolean;
};
export function DataTable<
@@ -70,6 +71,7 @@ export function DataTable<
emptyViewText,
emptyViewPositive,
getRowSx,
+ isRowSelected,
}: DataTableProps) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -79,6 +81,7 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
return (
@@ -108,6 +111,11 @@ export function DataTable<
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
+ ...(onRowClick && {
+ "&:hover": {
+ backgroundColor: theme.palette.action.rowHover,
+ },
+ }),
}}
key={key}
>
@@ -191,7 +199,7 @@ export function DataTable<
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
- fontWeight: 500,
+ fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -227,12 +235,25 @@ export function DataTable<
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
+ const selected = isRowSelected?.(row);
return (
{
@@ -292,6 +313,12 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent, newPage: number) => void;
}
+const controlHoverSx = (theme: Theme): SxProps => ({
+ "&:hover": {
+ backgroundColor: theme.palette.action.controlHover,
+ },
+});
+
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
@@ -321,6 +348,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
{
iconPosition="start"
sx={{
textTransform: "none",
- fontSize: 14,
+ fontSize: typographyLevels.m,
fontWeight: 500,
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
- fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
diff --git a/client/src/Components/inputs/Button.tsx b/client/src/Components/inputs/Button.tsx
index 4eae654b2..33bec7fe8 100644
--- a/client/src/Components/inputs/Button.tsx
+++ b/client/src/Components/inputs/Button.tsx
@@ -1,7 +1,7 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme, darken } from "@mui/material/styles";
-import { HOVER } from "@/Utils/Theme/constants";
+import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
- height: 34,
+ height: INPUT_BASE_HEIGHT,
+ lineHeight: 1.2,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
diff --git a/client/src/Components/inputs/ColorPicker.tsx b/client/src/Components/inputs/ColorPicker.tsx
index de736c85d..c65c3f8c7 100644
--- a/client/src/Components/inputs/ColorPicker.tsx
+++ b/client/src/Components/inputs/ColorPicker.tsx
@@ -4,6 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
- height: 34,
+ height: INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
diff --git a/client/src/Components/inputs/DatePicker.tsx b/client/src/Components/inputs/DatePicker.tsx
index c63c5ce08..3d82a0fa7 100644
--- a/client/src/Components/inputs/DatePicker.tsx
+++ b/client/src/Components/inputs/DatePicker.tsx
@@ -8,6 +8,7 @@ import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit, "label"> {
fieldLabel?: string;
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
- borderColor: error ? theme.palette.error.main : theme.palette.divider,
+ borderColor: error
+ ? theme.palette.error.main
+ : theme.palette.text.disabled,
},
},
},
diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx
index 0077d2be6..eda59a9d1 100644
--- a/client/src/Components/inputs/Select.tsx
+++ b/client/src/Components/inputs/Select.tsx
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps extends Omit, "label"> {
fieldLabel?: string;
@@ -71,7 +72,7 @@ const SelectInputInner = (
/>
)}
sx={{
- height: "34px",
+ height: `${INPUT_BASE_HEIGHT}px`,
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
@@ -83,10 +84,6 @@ const SelectInputInner = (
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
- borderColor: theme.palette.divider,
- },
- "&:hover .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
},
...props.sx,
}}
diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx
index 230e7650b..5fe644ca6 100644
--- a/client/src/Components/inputs/TextInput.tsx
+++ b/client/src/Components/inputs/TextInput.tsx
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
-import { LAYOUT } from "@/Utils/Theme/constants";
+import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit {
fieldLabel?: string;
@@ -22,16 +22,10 @@ export const TextInput = forwardRef(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
- height: props.multiline ? "auto" : 34,
+ height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
overflow: "hidden",
},
- "& .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
- },
- "&:hover .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
- },
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
@@ -39,7 +33,7 @@ export const TextInput = forwardRef(function T
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
- WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
+ WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
diff --git a/client/src/Components/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx
index b82df1f15..46345cfcb 100644
--- a/client/src/Components/inputs/TimePicker.tsx
+++ b/client/src/Components/inputs/TimePicker.tsx
@@ -7,6 +7,7 @@ import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit, "label"> {
fieldLabel?: string;
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
p: 0,
px: theme.spacing(5),
},
diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx
index a2b392357..7a344c071 100644
--- a/client/src/Components/inputs/ToggleButton.tsx
+++ b/client/src/Components/inputs/ToggleButton.tsx
@@ -4,6 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
- height: 34,
+ height: INPUT_BASE_HEIGHT,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
- fontWeight: 500,
"&:hover": {
backgroundColor: selectedHoverBg,
},
diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx
index 196ef2042..d746150ed 100644
--- a/client/src/Components/monitors/ControlsFilter.tsx
+++ b/client/src/Components/monitors/ControlsFilter.tsx
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
-const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
+const types = ["http", "ping", "port", "game", "grpc", "websocket"];
const typeDisplayNames: Record = {
http: "HTTP",
ping: "Ping",
port: "Port",
- docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx
index 8244f3875..9ab187c2b 100644
--- a/client/src/Components/monitors/MonitorListPage.tsx
+++ b/client/src/Components/monitors/MonitorListPage.tsx
@@ -35,7 +35,7 @@ export const MonitorListPage = ({
headerKey,
page,
actionLink,
- controller: c,
+ controller,
bulkActions,
showTypeFilter,
summaryProps,
@@ -48,14 +48,14 @@ export const MonitorListPage = ({
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
- const isLoading = c.isLoading || Boolean(extraLoading);
+ const isLoading = controller.isLoading || Boolean(extraLoading);
return (
{
- c.setSearch(event.target.value);
+ controller.setSearch(event.target.value);
}}
/>
- {c.selectedTags.length > 0 && (
+ {controller.selectedTags.length > 0 && (
{t("pages.uptime.filters.activeTags")}
- {c.selectedTags.map((tagId) => {
- const tag = c.tags?.find((t) => t.id === tagId);
+ {controller.selectedTags.map((tagId) => {
+ const tag = controller.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
}
- onClick={c.handleBulkResume}
+ onClick={controller.handleBulkResume}
>
{t("common.buttons.resume")}
}
- onClick={c.handleBulkPause}
+ onClick={controller.handleBulkPause}
>
{t("common.buttons.pause")}
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
)}
{children}
);
diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts
deleted file mode 100644
index 0516eaac3..000000000
--- a/client/src/Components/monitors/charts/ChartUtils.ts
+++ /dev/null
@@ -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",
-};
diff --git a/client/src/Components/monitors/charts/ChartUtils.tsx b/client/src/Components/monitors/charts/ChartUtils.tsx
new file mode 100644
index 000000000..c90fdb6d5
--- /dev/null
+++ b/client/src/Components/monitors/charts/ChartUtils.tsx
@@ -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";
+}) => (
+
+
+
+
+
+
+);
diff --git a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
new file mode 100644
index 000000000..b7a420fc9
--- /dev/null
+++ b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
@@ -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 (
+
+
+
+
+
+ {createGradient({
+ id: gradientId,
+ startColor: gradientStartColor,
+ endColor: "transparent",
+ direction: "vertical",
+ })}
+
+
+
+
+
+ );
+};
diff --git a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
index 8163898aa..bd3151364 100644
--- a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
+++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
@@ -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";
-}) => (
-
-
-
-
-
-
-);
+import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
export const HistogramInfrastructure = ({
dateRange,
diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index 2a7c359ec..dea3fbeb5 100644
--- a/client/src/Components/sidebar/Menu.tsx
+++ b/client/src/Components/sidebar/Menu.tsx
@@ -20,73 +20,99 @@ import {
Users,
Tag,
Waypoints,
+ Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
- name: t("components.sidebar.menu.dashboard"),
- path: "dashboard",
- icon: ,
+ group: t("components.sidebar.menu.groups.monitoring"),
+ items: [
+ {
+ name: t("components.sidebar.menu.dashboard"),
+ path: "dashboard",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.uptime"),
+ path: "uptime",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.docker"),
+ path: "docker",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.infrastructure"),
+ path: "infrastructure",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.pagespeed"),
+ path: "pagespeed",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.uptime"),
- path: "uptime",
- icon: ,
+ group: t("components.sidebar.menu.groups.alerting"),
+ items: [
+ {
+ name: t("components.sidebar.menu.incidents"),
+ path: "incidents",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.notifications"),
+ path: "notifications",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.maintenance"),
+ path: "maintenance",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.pagespeed"),
- path: "pagespeed",
- icon: ,
+ group: t("components.sidebar.menu.groups.reporting"),
+ items: [
+ {
+ name: t("components.sidebar.menu.statusPages"),
+ path: "status",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.checks"),
+ path: "checks",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.logs"),
+ path: "logs",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.infrastructure"),
- path: "infrastructure",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.notifications"),
- path: "notifications",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.tags"),
- path: "tags",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.proxies"),
- path: "proxies",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.checks"),
- path: "checks",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.incidents"),
- path: "incidents",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.statusPages"),
- path: "status",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.maintenance"),
- path: "maintenance",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.logs"),
- path: "logs",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.settings"),
- icon: ,
- path: "settings",
+ group: t("components.sidebar.menu.groups.configuration"),
+ items: [
+ {
+ name: t("components.sidebar.menu.tags"),
+ path: "tags",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.proxies"),
+ path: "proxies",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.settings"),
+ path: "settings",
+ icon: ,
+ },
+ ],
},
];
};
diff --git a/client/src/Components/sidebar/NavItem.tsx b/client/src/Components/sidebar/NavItem.tsx
index 24f2c5778..d573c884f 100644
--- a/client/src/Components/sidebar/NavItem.tsx
+++ b/client/src/Components/sidebar/NavItem.tsx
@@ -25,11 +25,10 @@ export const NavItem = ({
const iconStroke = selected
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
-
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
- ? theme.palette.action.selected
- : theme.palette.action.hover;
+ ? theme.palette.action.selectedHover
+ : theme.palette.action.rowHover;
const fontWeight = selected ? 600 : 400;
return (
{
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
- const { width, transition, collapsed } = useSidebar();
+ const { width, transition, transitionTiming, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
@@ -70,11 +73,11 @@ export const Sidebar = () => {
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
+ bgcolor={theme.palette.background.paper}
sx={{
touchAction: "none",
transition: transition,
zIndex: 1000,
- backdropFilter: "blur(8px)",
}}
>
{
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
- {menu.map((item) => {
- const selected = location.pathname.startsWith(`/${item.path}`);
- return (
- handleNavClick(item.path)}
- />
- );
- })}
+ {menu.map((group) => (
+
+
+
+ {group.group}
+
+
+
+ {group.items.map((item) => {
+ const selected = location.pathname.startsWith(`/${item.path}`);
+ return (
+ handleNavClick(item.path)}
+ />
+ );
+ })}
+
+ ))}
diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts
index f759b2957..a07b629f8 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -8,7 +8,8 @@ type TableName =
| "maintenance"
| "infrastructure"
| "logs"
- | "pagespeed";
+ | "pagespeed"
+ | "docker";
interface TableState {
rowsPerPage: number;
@@ -24,6 +25,7 @@ interface UIState {
team: TableState;
maintenance: TableState;
infrastructure: TableState;
+ docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -56,6 +58,9 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
+ docker: {
+ rowsPerPage: 5,
+ },
logs: {
rowsPerPage: 15,
},
diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts
index 543a9f165..343e0b558 100644
--- a/client/src/Hooks/UseApi.ts
+++ b/client/src/Hooks/UseApi.ts
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
-interface ApiResponse {
+export interface ApiResponse {
success: boolean;
msg: string;
data: T;
diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts
index a0da495bc..29f340238 100644
--- a/client/src/Hooks/UseToast.ts
+++ b/client/src/Hooks/UseToast.ts
@@ -1,27 +1,30 @@
import { toast, type ToastOptions } from "react-toastify";
+import { useMemo } from "react";
export const useToast = () => {
- const showToast = (message: string, options?: ToastOptions) => {
- toast.dismiss();
- const baseStyle: React.CSSProperties = {
- whiteSpace: "nowrap",
- overflow: "hidden",
- textOverflow: "ellipsis",
+ return useMemo(() => {
+ const showToast = (message: string, options?: ToastOptions) => {
+ toast.dismiss();
+ const baseStyle: React.CSSProperties = {
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ };
+ toast(message, {
+ ...options,
+ style: { ...baseStyle, ...(options?.style || {}) },
+ });
};
- toast(message, {
- ...options,
- style: { ...baseStyle, ...(options?.style || {}) },
- });
- };
- const toastSuccess = (msg: string, opts?: ToastOptions) =>
- showToast(msg, { ...opts, type: "success" });
- const toastError = (msg: string, opts?: ToastOptions) =>
- showToast(msg, { ...opts, type: "error" });
- const toastInfo = (msg: string, opts?: ToastOptions) =>
- showToast(msg, { ...opts, type: "info" });
- const toastWarning = (msg: string, opts?: ToastOptions) =>
- showToast(msg, { ...opts, type: "warning" });
+ const toastSuccess = (msg: string, opts?: ToastOptions) =>
+ showToast(msg, { ...opts, type: "success" });
+ const toastError = (msg: string, opts?: ToastOptions) =>
+ showToast(msg, { ...opts, type: "error" });
+ const toastInfo = (msg: string, opts?: ToastOptions) =>
+ showToast(msg, { ...opts, type: "info" });
+ const toastWarning = (msg: string, opts?: ToastOptions) =>
+ showToast(msg, { ...opts, type: "warning" });
- return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
+ return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
+ }, []);
};
diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index 5458a75d8..043796ccb 100644
--- a/client/src/Hooks/useMonitorForm.ts
+++ b/client/src/Hooks/useMonitorForm.ts
@@ -77,6 +77,7 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
+ dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
case "game":
diff --git a/client/src/Hooks/useSidebar.ts b/client/src/Hooks/useSidebar.ts
index 1cf705dd1..0229d8640 100644
--- a/client/src/Hooks/useSidebar.ts
+++ b/client/src/Hooks/useSidebar.ts
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes
-const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
+const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
+const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
export const useSidebar = () => {
const collapsed = useSelector(
@@ -19,6 +20,7 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION,
+ transitionTiming: SIDEBAR_TRANSITION_TIMING,
};
};
diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx
index 51fea25b6..b3c0f3293 100644
--- a/client/src/Pages/Account/components/HeaderTeamControls.tsx
+++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
}
+ startIcon={
+
+ }
onClick={onInviteClick}
>
{t("common.buttons.inviteMember")}
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
}
+ startIcon={
+
+ }
onClick={onAddMemberClick}
>
{t("common.buttons.addMember")}
diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx
index 93364af44..453660413 100644
--- a/client/src/Pages/Checks/index.tsx
+++ b/client/src/Pages/Checks/index.tsx
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
+import type { DateRange } from "@/Types/Query";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [statusFilter, setStatusFilter] = useState("down");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx
index 5c2b693e0..3658276cd 100644
--- a/client/src/Pages/CreateMonitor/index.tsx
+++ b/client/src/Pages/CreateMonitor/index.tsx
@@ -118,8 +118,8 @@ const getGeneralSettingsConfig = (
showDnsRecordType: false,
},
docker: {
- urlLabel: t("pages.createMonitor.form.general.option.container.label"),
- urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
+ urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
+ urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
@@ -255,6 +255,7 @@ const CreateMonitorPage = () => {
const firstSegment = pathSegments[0];
if (firstSegment === "pagespeed") return "pagespeed";
if (firstSegment === "infrastructure") return "hardware";
+ if (firstSegment === "docker") return "docker";
return "uptime";
}, [location.pathname]);
@@ -264,7 +265,9 @@ const CreateMonitorPage = () => {
? "pagespeed"
: pageType === "hardware"
? "hardware"
- : "http";
+ : pageType === "docker"
+ ? "docker"
+ : "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet(
isEditMode ? `/monitors/${monitorId}` : null
@@ -373,6 +376,8 @@ const CreateMonitorPage = () => {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
+ } else if (pageType === "docker") {
+ navigate("/docker");
} else {
navigate("/uptime");
}
@@ -823,6 +828,19 @@ const CreateMonitorPage = () => {
/>
)}
+ {showStep(1) && watchedType === "docker" && (
+
+ }
+ />
+ )}
+
{showStep(2) &&
(watchedType === "http" ||
watchedType === "grpc" ||
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx b/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx
new file mode 100644
index 000000000..b1ccddc2c
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx
@@ -0,0 +1,69 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { Dot } from "@/Components/design-elements";
+
+// Hooks
+import { useEffect, useState } from "react";
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+// Utils
+import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
+import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
+
+interface HeaderLogsProps {
+ lines: number;
+ loading: boolean;
+ lastUpdatedAt: number | null;
+}
+
+export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ if (lastUpdatedAt === null) return;
+ setNow(Date.now());
+ const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
+ return () => window.clearInterval(id);
+ }, [lastUpdatedAt]);
+
+ const renderStatus = () => {
+ if (loading) return t("pages.docker.container.logs.loading");
+ if (lastUpdatedAt === null) return null;
+ const ageMs = Math.max(0, now - lastUpdatedAt);
+ return ageMs < MS_PER_MINUTE
+ ? t("pages.docker.container.logs.updatedSecondsAgo", {
+ count: Math.floor(ageMs / MS_PER_SECOND),
+ })
+ : t("pages.docker.container.logs.updatedMinutesAgo", {
+ count: Math.floor(ageMs / MS_PER_MINUTE),
+ });
+ };
+
+ return (
+
+
+ {t("pages.docker.container.logs.recentLines", { count: lines })}
+
+
+ {lastUpdatedAt !== null && !loading && (
+
+ )}
+ {renderStatus()}
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx
new file mode 100644
index 000000000..4a022e44e
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx
@@ -0,0 +1,78 @@
+import Grid from "@mui/material/Grid";
+import Typography from "@mui/material/Typography";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Divider from "@mui/material/Divider";
+import { Fragment } from "react";
+
+// Hooks
+import { useTheme } from "@mui/material";
+import { useSelector } from "react-redux";
+import { useTranslation } from "react-i18next";
+
+// Utils
+import type { DockerLogRow } from "@/Utils/MonitorUtils";
+import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
+import { formatDateWithTz } from "@/Utils/TimeUtils";
+import type { RootState } from "@/store";
+
+export const RowLog = ({ row }: { row: DockerLogRow }) => {
+ const theme = useTheme();
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
+
+
+
+
+
+ {row.stream}
+
+ : {row.text}
+
+
+
+ {row.gapBefore ? (
+
+
+ {t("pages.docker.container.logs.skipped")}
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
new file mode 100644
index 000000000..ed97917bf
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
@@ -0,0 +1,51 @@
+import Stack from "@mui/material/Stack";
+import { StatBox } from "@/Components/design-elements";
+
+// Types
+
+// 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 = ({
+ container,
+}: {
+ 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 (
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx
new file mode 100644
index 000000000..e7e29207d
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx
@@ -0,0 +1,241 @@
+// Components
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { BaseBox, EmptyState } from "@/Components/design-elements";
+import { Button } from "@/Components/inputs";
+import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
+import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
+
+// Types
+import type { DockerContainerLogsResponse } from "@/Types/Monitor";
+import type { DockerLog } from "@/Types/Check";
+import type { ApiResponse } from "@/Hooks/UseApi";
+
+// Hooks
+import {
+ useState,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useCallback,
+} from "react";
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import { useToast } from "@/Hooks/UseToast";
+
+// Utils
+import { get } from "@/Utils/ApiClient";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { flattenDockerLogs } from "@/Utils/MonitorUtils";
+
+interface TabLogsProps {
+ monitorId?: string;
+ containerName?: string;
+ enabled: boolean;
+}
+
+const SCROLL_PIN_THRESHOLD_PX = 8;
+const TAIL_POLL_MS = 10_000;
+
+const isScrolledToBottom = (el: HTMLElement) =>
+ el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
+
+const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
+ const res = await get>(url, { params });
+ return res.data.data;
+};
+
+export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { toastError } = useToast();
+
+ // logs is newest-first, matching the API and what flattenDockerLogs expects.
+ const [logs, setLogs] = useState([]);
+ const [olderExhausted, setOlderExhausted] = useState(false);
+ const [loadingInitial, setLoadingInitial] = useState(false);
+ const [loadingOlder, setLoadingOlder] = useState(false);
+ const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
+
+ const logsUrl =
+ enabled && monitorId && containerName
+ ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
+ : null;
+
+ const newestCursor = logs[0]?.checkedAt;
+ const oldestCursor = logs[logs.length - 1]?.checkedAt;
+
+ // Initial page. Resets everything when the container changes.
+ useEffect(() => {
+ setLogs([]);
+ setOlderExhausted(false);
+ if (!logsUrl) return;
+ let cancelled = false;
+ setLoadingInitial(true);
+ fetchLogs(logsUrl, {})
+ .then((page) => {
+ if (cancelled) return;
+ setLogs(page.logs);
+ setOlderExhausted(page.nextCursor === null);
+ setLastUpdatedAt(Date.now());
+ })
+ .catch(() => {
+ if (cancelled) return;
+ toastError(t("pages.docker.container.logs.loadError"));
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingInitial(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [logsUrl, t, toastError]);
+
+ const newestCursorRef = useRef(newestCursor);
+ newestCursorRef.current = newestCursor;
+
+ useEffect(() => {
+ if (!logsUrl) return;
+ let inFlight = false;
+ const tick = async () => {
+ if (inFlight || document.hidden) return;
+ inFlight = true;
+ try {
+ const cursor = newestCursorRef.current;
+ const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
+ if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
+ setLastUpdatedAt(Date.now());
+ } catch (err: unknown) {
+ console.error(err);
+ } finally {
+ inFlight = false;
+ }
+ };
+ const id = window.setInterval(tick, TAIL_POLL_MS);
+ document.addEventListener("visibilitychange", tick);
+ return () => {
+ window.clearInterval(id);
+ document.removeEventListener("visibilitychange", tick);
+ };
+ }, [logsUrl]);
+
+ const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
+
+ const logBoxRef = useRef(null);
+ const pinnedToBottom = useRef(true);
+ const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
+
+ // Always pin to bottom when container/monitor changes
+ useLayoutEffect(() => {
+ pinnedToBottom.current = true;
+ }, [monitorId, containerName]);
+
+ // Rows changed: restore position after a prepend, otherwise honour the pin
+ useLayoutEffect(() => {
+ const el = logBoxRef.current;
+ if (!el) return;
+ const anchor = prependAnchor.current;
+ if (anchor) {
+ prependAnchor.current = null;
+ el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
+ return;
+ }
+ if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
+ }, [rows]);
+
+ const handleScroll = useCallback((event: React.UIEvent) => {
+ pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
+ }, []);
+
+ const loadOlder = useCallback(async () => {
+ if (!logsUrl || !oldestCursor || loadingOlder) return;
+ setLoadingOlder(true);
+ try {
+ const page = await fetchLogs(logsUrl, { before: oldestCursor });
+ const el = logBoxRef.current;
+ if (el)
+ prependAnchor.current = {
+ scrollHeight: el.scrollHeight,
+ scrollTop: el.scrollTop,
+ };
+ setLogs((prev) => [...prev, ...page.logs]);
+ setOlderExhausted(page.nextCursor === null);
+ } catch {
+ toastError(t("pages.docker.container.logs.loadOlderError"));
+ } finally {
+ setLoadingOlder(false);
+ }
+ }, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
+
+ if (!enabled) {
+ return (
+
+ );
+ }
+
+ if (lastUpdatedAt !== null && rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {rows.length > 0 && (
+
+ {olderExhausted ? (
+
+ {t("pages.docker.container.logs.noOlder")}
+
+ ) : (
+
+ )}
+
+ )}
+ {rows.map((row) => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
new file mode 100644
index 000000000..32ad93c2f
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
@@ -0,0 +1,113 @@
+// Components
+import Grid from "@mui/material/Grid";
+import Stack from "@mui/material/Stack";
+import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
+
+// Hooks
+import { Typography, 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";
+import { BaseBox } from "@/Components/design-elements";
+import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
+
+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"));
+
+ const { ports, mounts } = stats?.latest?.container ?? {};
+ const dedupedPorts = dedupeDockerPorts(ports ?? []);
+ return (
+
+ {getChartConfigs(theme, t, stats).map((config) => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+ Ports
+
+
+ {dedupedPorts?.map((port) => {
+ return (
+ {`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}
+ );
+ })}
+
+
+
+
+
+
+ Volumes
+
+
+ {mounts?.map((mount) => {
+ return (
+
+ {`${getDockerMountLabel(mount)} -> ${mount.destination}`}
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx
new file mode 100644
index 000000000..6bd30c733
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/index.tsx
@@ -0,0 +1,84 @@
+// Components
+import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
+import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
+import { TabLogs } from "@/Pages/Docker/ContainerDetails/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/ContainerDetails/Components/StatBoxes";
+import type { DateRange } from "@/Types/Query";
+import { HeaderTimeRange } from "@/Components/common";
+interface DockerContainerDetailsProps {
+ open?: "overview" | "logs";
+}
+
+const TAB_MAP = {
+ overview: 0,
+ logs: 1,
+} as const;
+
+const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
+ const [activeTab, setActiveTab] = useState(TAB_MAP[open]);
+ const [dateRange, setDateRange] = useState("recent");
+ const { monitorId, containerName } = useParams<{
+ monitorId: string;
+ containerName: string;
+ }>();
+ const { t } = useTranslation();
+
+ const containerDetailsUrl = monitorId
+ ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
+ : null;
+
+ const { data: containerData } = useGet(
+ containerDetailsUrl,
+ {},
+ { 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 (
+
+
+
+
+ setActiveTab(newValue)}
+ >
+
+
+
+ {activeTab === 0 && }
+ {activeTab === 1 && (
+
+ )}
+
+ );
+};
+
+export default DockerDetailsPage;
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..72d828f45
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx
@@ -0,0 +1,123 @@
+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 {
+ monitorId: string | undefined;
+ containers: DockerContainerInfo[];
+ onRowClick?: (container: DockerContainerInfo) => void;
+}
+
+export const DockerContainersTable = ({
+ monitorId,
+ 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: t("pages.docker.host.table.headers.container"),
+ render: (row) => {
+ return (
+
+ );
+ },
+ },
+ {
+ id: "status",
+ content: t("common.table.headers.status"),
+ render: (row) => {
+ return {row.state};
+ },
+ },
+ {
+ id: "health",
+ content: t("common.labels.health"),
+ render: (row) => {
+ return {row.health};
+ },
+ },
+ {
+ id: "cpu",
+ content: t("pages.docker.host.table.headers.cpu"),
+ render: (row) => {
+ return {formatPercentage(row.cpuPct ?? 0)};
+ },
+ },
+ {
+ id: "memory",
+ content: t("pages.docker.host.table.headers.memory"),
+ render: (row) => {
+ return {formatPercentage(row.memoryPct ?? 0)};
+ },
+ },
+ {
+ id: "restarts",
+ content: t("common.labels.restarts"),
+ render: (row) => {
+ return {row.restartCount};
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+
+ return (
+
+ {
+ navigate(`/docker/host/${monitorId}/container/${row.name}`);
+ }}
+ // 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..1f65836b0
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
@@ -0,0 +1,47 @@
+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
+
+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..7c2d3c80c
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/index.tsx
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 000000000..ea06557f6
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
@@ -0,0 +1,343 @@
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import {
+ Table,
+ Pagination,
+ StatusLabel,
+ ColoredLabel,
+} from "@/Components/design-elements";
+import { SPACING } from "@/Utils/Theme/constants";
+import type { Header } from "@/Components/design-elements/Table";
+import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
+import { ArrowUp, ArrowDown } from "lucide-react";
+
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+import { usePost } from "@/Hooks/UseApi";
+
+import type { Monitor } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import type { DockerContainerSummary } from "@/Types/Check";
+import { Checkbox } from "@/Components/inputs";
+
+const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
+ const recentChecks = monitor.recentChecks ?? [];
+ return recentChecks[recentChecks.length - 1]?.containerSummary;
+};
+
+interface DockerMonitorsTableProps {
+ monitors: Monitor[];
+ tags: Tag[];
+ refetch: () => void;
+ setSelectedMonitor: (monitor: Monitor | null) => void;
+ sortField: string;
+ setSortField: (field: string) => void;
+ sortOrder: "asc" | "desc";
+ setSortOrder: (order: "asc" | "desc") => void;
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+ selectedRows?: string[];
+ onSelectionChange?: (selected: string[]) => void;
+}
+
+export const DockerMonitorsTable = ({
+ monitors,
+ tags,
+ refetch,
+ setSelectedMonitor,
+ sortField,
+ setSortField,
+ sortOrder,
+ setSortOrder,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+ selectedRows = [],
+ onSelectionChange,
+}: DockerMonitorsTableProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const navigate = useNavigate();
+ const { post } = usePost, Monitor>();
+
+ const selectedSet = new Set(selectedRows);
+ const isAllSelected =
+ monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
+ const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
+
+ const handleSelectAll = (checked: boolean) => {
+ onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
+ };
+
+ const handleSelectRow = (id: string, checked: boolean) => {
+ if (!onSelectionChange) return;
+ onSelectionChange(
+ checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
+ );
+ };
+
+ const isRowSelected = (id: string) => selectedSet.has(id);
+
+ const handlePageChange = (
+ _e: React.MouseEvent | null,
+ newPage: number
+ ) => {
+ setPage(newPage);
+ };
+
+ const handleRowsPerPageChange = (
+ e: React.ChangeEvent
+ ) => {
+ const value = Number(e.target.value);
+ setPage(0);
+ setRowsPerPage(value);
+ };
+
+ const handleSort = (e: React.MouseEvent, field: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (sortField === field) {
+ const newOrder = sortOrder === "asc" ? "desc" : "asc";
+ setSortOrder(newOrder);
+ } else {
+ setSortField(field);
+ setSortOrder("asc");
+ }
+ refetch();
+ };
+
+ const getActions = (monitor: Monitor): ActionMenuItem[] => {
+ return [
+ {
+ id: 2,
+ label: t("pages.common.monitors.actions.details"),
+ action: () => {
+ navigate(`${monitor.id}`);
+ },
+ },
+ {
+ id: 3,
+ label: t("pages.common.monitors.actions.incidents"),
+ action: () => {
+ navigate(`/incidents?monitorId=${monitor.id}`);
+ },
+ },
+ {
+ id: 4,
+ label: t("pages.common.monitors.actions.configure"),
+ action: () => {
+ navigate(`/docker/configure/${monitor.id}`);
+ },
+ },
+ {
+ id: 6,
+ label:
+ monitor.isActive === false
+ ? t("common.buttons.resume")
+ : t("common.buttons.pause"),
+ action: async () => {
+ await post(`/monitors/pause/${monitor.id}`, {});
+ refetch();
+ },
+ closeMenu: true,
+ },
+ {
+ id: 7,
+ label: (
+
+ {t("common.buttons.delete")}
+
+ ),
+ action: () => {
+ setSelectedMonitor(monitor);
+ },
+ closeMenu: true,
+ },
+ ];
+ };
+
+ const getHeaders = () => {
+ const renderSortIcon = (isActive: boolean) => (
+
+ {isActive ? (
+ sortOrder === "asc" ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ );
+ const headers: Header[] = [
+ {
+ id: "selection",
+ content: (
+ handleSelectAll(e.target.checked)}
+ />
+ ),
+ hideMobileLabel: true,
+ render: (row) => (
+ {
+ handleSelectRow(row.id, e.target.checked);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ),
+ onClick: (e) => e?.stopPropagation(),
+ },
+ {
+ id: "name",
+ align: "left",
+ mobileLabel: t("pages.docker.table.headers.host"),
+ content: (
+ handleSort(e, "name")}
+ sx={{ cursor: "pointer" }}
+ >
+ {t("pages.docker.table.headers.host")}
+ {renderSortIcon(sortField === "name")}
+
+ ),
+ render: (row) => {
+ return (
+
+ {row.name}
+
+ {row.url}
+
+
+ );
+ },
+ },
+ {
+ id: "status",
+ mobileLabel: t("common.table.headers.status"),
+ content: (
+ handleSort(e, "status")}
+ sx={{ cursor: "pointer" }}
+ >
+
+ {t("common.table.headers.status")}
+ {renderSortIcon(sortField === "status")}
+
+ ),
+ render: (row) => {
+ return ;
+ },
+ },
+ {
+ id: "containers",
+ content: t("pages.docker.table.headers.containers"),
+ render: (row) => {
+ const summary = summaryOf(row);
+ return (
+
+ {summary
+ ? t("pages.docker.table.containersRunning", {
+ running: summary.running,
+ total: summary.total,
+ })
+ : "—"}
+
+ );
+ },
+ },
+ {
+ id: "tags",
+ content: t("common.table.headers.tags"),
+ render: (row) => {
+ if (row.tags.length === 0) return "-";
+ return (
+
+ {row.tags.map((tagId) => {
+ const fullTag = tags.find((t) => t.id === tagId);
+ if (!fullTag) return null;
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
+
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+ return (
+
+ {
+ navigate(`/docker/host/${row.id}`);
+ }}
+ isRowSelected={(row) => isRowSelected(row.id)}
+ />
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/Monitors/index.tsx b/client/src/Pages/Docker/Monitors/index.tsx
new file mode 100644
index 000000000..96d13abbf
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/index.tsx
@@ -0,0 +1,44 @@
+import { MonitorListPage } from "@/Components/monitors";
+import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
+
+const DockerMonitorsPage = () => {
+ const c = useMonitorListController({
+ types: ["docker"],
+ checksLimit: 25,
+ refreshInterval: 5000,
+ rowsPerPageTable: "docker",
+ rowsPerPageDefault: 10,
+ });
+
+ return (
+
+
+
+ );
+};
+
+export default DockerMonitorsPage;
diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx
index 60dadaa74..75c9672a0 100644
--- a/client/src/Pages/Incidents/Components/CardSummary.tsx
+++ b/client/src/Pages/Incidents/Components/CardSummary.tsx
@@ -30,6 +30,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
direction="row"
alignItems="center"
gap={theme.spacing(2)}
+ sx={{ "& svg": { color: theme.palette.text.secondary } }}
>
{icon}
{label}
@@ -143,7 +144,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
-
+
{
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
diff --git a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
index 4309feca7..4ef5fa145 100644
--- a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
@@ -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,
diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
index c84a327fa..b302b939d 100644
--- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
@@ -63,19 +63,42 @@ const getChartConfigs = (
return configs;
};
-// Loopback / bridge names Capture sees when it can only observe the container's
-// own network namespace. When ALL reported interfaces match this set we assume
-// the "no real interfaces visible" case and surface the notice.
+// Loopback / bridge names Capture reports when it can only observe the
+// container's own network namespace.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
+// Below this, an interface is carrying no traffic worth charting. Real host
+// NICs sit orders of magnitude above it; a container's veth sits at or near
+// zero.
+const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
+
+const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
+ checks.every((check) => {
+ const iface = check.net?.find((candidate) => candidate.name === name);
+ if (!iface) {
+ return true;
+ }
+ return (
+ (iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
+ (iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
+ );
+ });
+
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
- if (named.size === 0) return false;
- for (const name of named) {
- if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
+ if (named.size === 0) {
+ return false;
}
- return true;
+
+ const names = [...named];
+ if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
+ return true;
+ }
+ if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
+ return false;
+ }
+ return names.every((name) => interfaceIsIdle(checks, name));
};
export const InfraNetworkCharts = ({
diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
index e2b053aef..eba54070d 100644
--- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check";
+const GAUGE_MAX_WIDTH = 260;
+
export const InfraDetailsGauges = ({
snapshot,
}: {
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
+ flexWrap={"wrap"}
+ useFlexGap
>
{snapshot?.disk?.map((disk, idx) => {
return (
);
})}
diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx
index 202887ad5..ad1d4a334 100644
--- a/client/src/Pages/Infrastructure/Details/index.tsx
+++ b/client/src/Pages/Infrastructure/Details/index.tsx
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
+import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => {
const { t } = useTranslation();
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>();
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [selectedTab, setSelectedTab] = useState(0);
const monitorDetailsUrl = useMemo(() => {
diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
index 7c3c003f4..567fd3f9e 100644
--- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
+++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
@@ -331,11 +331,7 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
- getRowSx={(row) => ({
- backgroundColor: isRowSelected(row.id)
- ? theme.palette.action.selected
- : "inherit",
- })}
+ isRowSelected={(row) => isRowSelected(row.id)}
/>
{
);
const { data: monitors } = useGet("/monitors/team");
+ const { data: tags } = useGet("/tags/team");
const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch();
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
+ renderOptionContent={(monitor) => (
+
+ {monitor.name}
+ {monitor.tags.map((tagId) => {
+ const tag = tags?.find(({ id }) => id === tagId);
+ return tag ? (
+
+ ) : null;
+ })}
+
+ )}
/>
}
/>
diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx
index ea9da4a7f..9b4e40b7c 100644
--- a/client/src/Pages/PageSpeed/Details/index.tsx
+++ b/client/src/Pages/PageSpeed/Details/index.tsx
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
+import type { DateRange } from "@/Types/Query";
const PageSpeedDetails = () => {
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
- const [dateRange, setDateRange] = useState("day");
+ const [dateRange, setDateRange] = useState("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx
index 70a8e1bb6..c8fccfd3f 100644
--- a/client/src/Pages/Settings/index.tsx
+++ b/client/src/Pages/Settings/index.tsx
@@ -1,4 +1,4 @@
-import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
+import { BasePage, ConfigBox, TextLink, Tabs, Tab } from "@/Components/design-elements";
import { Autocomplete, Select, Dialog } from "@/Components/inputs";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
@@ -46,6 +46,80 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import type { ProxyResponse } from "@/Types/Proxy";
import type { AppSettingsResponse } from "@/Types/Settings";
+import { useSearchParams } from "react-router-dom";
+
+const SETTINGS_TABS = [
+ { key: "general", labelKey: "pages.settings.tabs.general" },
+ { key: "monitoring", labelKey: "pages.settings.tabs.monitoring", adminOnly: true },
+ { key: "email", labelKey: "pages.settings.tabs.email", adminOnly: true },
+ { key: "integrations", labelKey: "pages.settings.tabs.integrations", adminOnly: true },
+ { key: "data", labelKey: "pages.settings.tabs.data", adminOnly: true },
+] as const;
+
+type SettingsTabKey = (typeof SETTINGS_TABS)[number]["key"];
+
+const FIELD_TAB: Record = {
+ checkTTL: "monitoring",
+ globalThresholds: "monitoring",
+ pagespeedApiKey: "integrations",
+ globalProxyEnabled: "integrations",
+ globalProxyId: "integrations",
+ showURL: "general",
+};
+
+const tabForField = (field: string): SettingsTabKey =>
+ field.startsWith("systemEmail")
+ ? "email"
+ : (FIELD_TAB[field.split(".")[0]] ?? "general");
+
+const FIELD_LABEL_KEY: Record = {
+ checkTTL: "pages.settings.form.retention.option.days.label",
+ "globalThresholds.cpu": "pages.settings.form.thresholds.option.cpu.label",
+ "globalThresholds.memory": "pages.settings.form.thresholds.option.memory.label",
+ "globalThresholds.disk": "pages.settings.form.thresholds.option.disk.label",
+ "globalThresholds.temperature":
+ "pages.settings.form.thresholds.option.temperature.label",
+ pagespeedApiKey: "pages.settings.form.pagespeed.option.apiKey.label",
+ globalProxyEnabled: "pages.settings.form.globalProxy.option.enabled.label",
+ showURL: "pages.settings.form.url.option.showURL.label",
+ systemEmailHost: "pages.settings.form.email.option.host.label",
+ systemEmailPort: "pages.settings.form.email.option.port.label",
+ systemEmailAddress: "pages.settings.form.email.option.address.label",
+ systemEmailDisplayName: "pages.settings.form.email.option.displayName.label",
+ systemEmailUser: "pages.settings.form.email.option.user.label",
+ systemEmailPassword: "pages.settings.form.email.option.password.label",
+ systemEmailTLSServername: "pages.settings.form.email.option.tlsServername.label",
+ systemEmailConnectionHost: "pages.settings.form.email.option.connectionHost.label",
+ systemEmailSecure: "pages.settings.form.email.option.secure.label",
+ systemEmailPool: "pages.settings.form.email.option.pool.label",
+ systemEmailIgnoreTLS: "pages.settings.form.email.option.ignoreTLS.label",
+ systemEmailRequireTLS: "pages.settings.form.email.option.requireTLS.label",
+ systemEmailRejectUnauthorized:
+ "pages.settings.form.email.option.rejectUnauthorized.label",
+};
+
+type FieldErrorLeaf = { path: string; message?: string };
+
+const flattenFieldErrors = (errors: unknown, prefix = ""): FieldErrorLeaf[] => {
+ if (typeof errors !== "object" || errors === null) {
+ return [];
+ }
+ return Object.entries(errors).flatMap(([key, value]) => {
+ if (key === "ref") {
+ return [];
+ }
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof value !== "object" || value === null) {
+ return [];
+ }
+ const message = (value as { message?: unknown }).message;
+ if (typeof message === "string") {
+ return [{ path, message }];
+ }
+ const nested = flattenFieldErrors(value, path);
+ return nested.length > 0 ? nested : [{ path }];
+ });
+};
export const SettingsPage = () => {
const theme = useTheme();
@@ -53,6 +127,42 @@ export const SettingsPage = () => {
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
const { toastError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const visibleTabs = useMemo(
+ () => SETTINGS_TABS.filter((tab) => !("adminOnly" in tab) || isAdmin),
+ [isAdmin]
+ );
+
+ const requestedTab = searchParams.get("tab");
+ const firstVisibleTab: SettingsTabKey = visibleTabs[0]?.key ?? SETTINGS_TABS[0].key;
+ const activeTab: SettingsTabKey =
+ visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? firstVisibleTab;
+
+ const handleTabChange = (tab: SettingsTabKey) => {
+ const updated = new URLSearchParams(searchParams);
+ if (tab === firstVisibleTab) {
+ updated.delete("tab");
+ } else {
+ updated.set("tab", tab);
+ }
+ setSearchParams(updated, { replace: true });
+ };
+
+ // Drop a "tab" param that does not resolve to a rendered tab, or that names
+ // the first tab, which is addressed with no param at all.
+ useEffect(() => {
+ if (requestedTab === null) {
+ return;
+ }
+ if (requestedTab === activeTab && activeTab !== firstVisibleTab) {
+ return;
+ }
+ const updated = new URLSearchParams(searchParams);
+ updated.delete("tab");
+ setSearchParams(updated, { replace: true });
+ }, [requestedTab, activeTab, firstVisibleTab, searchParams, setSearchParams]);
+
// Local state for demo monitors dialog
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
@@ -300,6 +410,13 @@ export const SettingsPage = () => {
logger.debug("Form validation errors", errors);
};
+ const revealField = (field: string) => {
+ const target = tabForField(field);
+ if (visibleTabs.some((tab) => tab.key === target)) {
+ handleTabChange(target);
+ }
+ };
+
const languages = Object.keys(i18n.options.resources || {});
const globalProxyEnabled = watch("globalProxyEnabled");
@@ -324,545 +441,594 @@ export const SettingsPage = () => {
component="form"
onSubmit={form.handleSubmit(onSubmit, onError)}
>
-
- option.name}
- isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) =>
- option.id === value.id
- }
- onChange={(_, newValue: TimezoneOption | null) => {
- handleTimezoneChange(newValue);
- }}
- fieldLabel={t("pages.settings.form.timezone.option.timezone.label")}
+ {visibleTabs.length > 1 && (
+ handleTabChange(value)}
+ >
+ {visibleTabs.map((tab) => (
+
- }
- />
-
-
-
-
-
-
- }
- />
- {isAdmin && (
- 0 ? (
-
-
- {globalProxyEnabled && (
-
- )}
-
- ) : (
-
- )
- }
- />
- )}
- {isAdmin && (
-
- {(isApiKeySet === false || apiKeyHasBeenReset === true) && (
-
- )}
+ ))}
+
+ )}
- {isApiKeySet === true && apiKeyHasBeenReset === false && (
-
-
- {t("pages.settings.form.pagespeed.option.apiKey.labelSet")}
-
-
-
- )}
- >
- }
- />
- )}
-
- {/* URL Settings */}
-
- }
- />
-
- {/* Clear All Stats */}
- {isAdmin && (
+ {/* general tab */}
+ {activeTab === "general" && (
+
setIsStatsDialogOpen(true)}
- >
- {t("common.buttons.clear")}
-
- }
- />
- )}
-
- {/* Check Retention */}
- {isAdmin && (
-
- value >= CHECK_TTL_SENTINEL
- ? t("pages.settings.form.retention.option.days.unlimited")
- : `${value}`
- }
- formatDisplayValue={(value: number) =>
- value >= CHECK_TTL_SENTINEL
- ? t("pages.settings.form.retention.option.days.unlimited")
- : `${value}`
+ option.name}
+ isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) =>
+ option.id === value.id
}
+ onChange={(_, newValue: TimezoneOption | null) => {
+ handleTimezoneChange(newValue);
+ }}
+ fieldLabel={t("pages.settings.form.timezone.option.timezone.label")}
/>
}
/>
- )}
-
- {/* Global Thresholds */}
- {isAdmin && (
-
-
-
-
-
+
+
+
+
+
}
/>
- )}
-
-
- {/* Email Settings - Admin Only */}
- {isAdmin && (
-
-
- {(() => {
- const address = form.watch("systemEmailAddress") || "";
- const displayName = form.watch("systemEmailDisplayName")?.trim();
- return (
- <>
-
-
- {JSON.stringify(
- {
- host: form.watch("systemEmailHost") || "",
- port: form.watch("systemEmailPort") || "",
- secure: form.watch("systemEmailSecure") ?? false,
- auth: {
- user: form.watch("systemEmailUser") || address,
- pass: "",
- },
- name:
- form.watch("systemEmailConnectionHost") || "localhost",
- pool: form.watch("systemEmailPool") ?? false,
- tls: {
- rejectUnauthorized:
- form.watch("systemEmailRejectUnauthorized") ?? true,
- ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
- requireTLS: form.watch("systemEmailRequireTLS") ?? false,
- servername: form.watch("systemEmailTLSServername") || "",
- },
- },
- null,
- 2
- )}
-
-
- {address && (
-
-
- {`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
-
-
+ }
+ />
+ {/* About */}
+
+
+ {t("common.appName")} {__APP_VERSION__}
+
+
+ {t("pages.settings.form.about.developedBy")}
+
+
+ https://github.com/bluewave-labs/checkmate
+
+
+ }
+ />
+
+ )}
+
+ {/* monitoring tab */}
+ {activeTab === "monitoring" && (
+
+ {/* Global Thresholds */}
+ {isAdmin && (
+
+
+
+
+
+
- );
- })()}
-
- }
- rightContent={
-
- {/* Email Host */}
-
+ valueLabelDisplay="auto"
+ min={1}
+ max={150}
+ />
+
+ }
+ />
+ )}
+ {/* Check Retention */}
+ {isAdmin && (
+
+ value >= CHECK_TTL_SENTINEL
+ ? t("pages.settings.form.retention.option.days.unlimited")
+ : `${value}`
+ }
+ formatDisplayValue={(value: number) =>
+ value >= CHECK_TTL_SENTINEL
+ ? t("pages.settings.form.retention.option.days.unlimited")
+ : `${value}`
+ }
+ />
+ }
+ />
+ )}
- {/* Email Port */}
-
-
- {/* Email Address */}
-
-
- {/* Email Display Name (Optional) */}
-
-
- {/* Email User (Optional) */}
-
-
- {/* Email Password with Reset Pattern */}
- {isEmailPasswordSet && !emailPasswordHasBeenReset ? (
+ {/* Demo Monitors - Admin Only */}
+ {isAdmin && (
+
-
- {t("pages.settings.form.email.option.password.labelSet")}
-
- {
+ await postDemoMonitors("/monitors/demo", {});
+ }}
+ >
+ {t("common.buttons.addDemo")}
+
+
+ }
+ />
+ )}
+
+ )}
+
+ {/* email tab */}
+ {activeTab === "email" && (
+
+ {/* Email Settings - Admin Only */}
+ {isAdmin && (
+
+
+ {(() => {
+ const address = form.watch("systemEmailAddress") || "";
+ const displayName = form.watch("systemEmailDisplayName")?.trim();
+ return (
+ <>
+
+
+ {JSON.stringify(
+ {
+ host: form.watch("systemEmailHost") || "",
+ port: form.watch("systemEmailPort") || "",
+ secure: form.watch("systemEmailSecure") ?? false,
+ auth: {
+ user: form.watch("systemEmailUser") || address,
+ pass: "",
+ },
+ name:
+ form.watch("systemEmailConnectionHost") ||
+ "localhost",
+ pool: form.watch("systemEmailPool") ?? false,
+ tls: {
+ rejectUnauthorized:
+ form.watch("systemEmailRejectUnauthorized") ?? true,
+ ignoreTLS:
+ form.watch("systemEmailIgnoreTLS") ?? false,
+ requireTLS:
+ form.watch("systemEmailRequireTLS") ?? false,
+ servername:
+ form.watch("systemEmailTLSServername") || "",
+ },
+ },
+ null,
+ 2
+ )}
+
+
+ {address && (
+
+
+ {`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
+
+
+ )}
+ >
+ );
+ })()}
+
+ }
+ rightContent={
+
+ {/* Email Host */}
+
+
+ {/* Email Port */}
+
+
+ {/* Email Address */}
+
+
+ {/* Email Display Name (Optional) */}
+
+
+ {/* Email User (Optional) */}
+
+
+ {/* Email Password with Reset Pattern */}
+ {isEmailPasswordSet && !emailPasswordHasBeenReset ? (
+
+
+ {t("pages.settings.form.email.option.password.labelSet")}
+
+
+
+
+
+ ) : (
+
+ )}
+
+ {/* TLS Servername (Optional) */}
+
+
+ {/* Connection Host (Optional) */}
+
+
+ {/* Boolean Switches */}
+
+ {emailSwitches.map(({ name, label }) => (
+
+ ))}
+
+
+ {/* Test Email Button */}
+
+
+
+ }
+ />
+ )}
+
+ )}
+
+ {/* integrations tab */}
+ {activeTab === "integrations" && (
+
+ {isAdmin && (
+ 0 ? (
+
+
+ {globalProxyEnabled && (
+
+ )}
-
- ) : (
-
- )}
-
- {/* TLS Servername (Optional) */}
-
-
- {/* Connection Host (Optional) */}
-
-
- {/* Boolean Switches */}
-
- {emailSwitches.map(({ name, label }) => (
-
- ))}
-
+ )
+ }
+ />
+ )}
+ {isAdmin && (
+
+ {(isApiKeySet === false || apiKeyHasBeenReset === true) && (
+
+ )}
- {/* Test Email Button */}
-
+ {isApiKeySet === true && apiKeyHasBeenReset === false && (
+
+
+ {t("pages.settings.form.pagespeed.option.apiKey.labelSet")}
+
+
+
+ )}
+ >
+ }
+ />
+ )}
+
+ )}
+
+ {/* data tab */}
+ {activeTab === "data" && (
+
+ {/* Export Monitors - Admin Only */}
+ {isAdmin && (
+
+
+
+
+
+ }
+ />
+ )}
+ {/* Clear All Stats */}
+ {isAdmin && (
+ setIsStatsDialogOpen(true)}
>
- {t("common.buttons.sendTestEmail")}
+ {t("common.buttons.clear")}
-
-
- }
- />
- )}
+ }
+ />
+ )}
- {/* Demo Monitors - Admin Only */}
- {isAdmin && (
-
-
-
- }
- />
+ {/* Remove All Monitors - Admin Only */}
+ {isAdmin && (
+
+
+
+ }
+ />
+ )}
+
)}
- {/* Remove All Monitors - Admin Only */}
- {isAdmin && (
-
-
-
- }
- />
- )}
-
- {/* Export Monitors - Admin Only */}
- {isAdmin && (
-
-
-
-
-
- }
- />
- )}
-
- {/* About */}
-
-
- {t("common.appName")} {__APP_VERSION__}
-
-
- {t("pages.settings.form.about.developedBy")}
-
-
- https://github.com/bluewave-labs/checkmate
-
-
- }
- />
-
{/* Clear Stats Confirmation Dialog */}