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 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) - -image +image


@@ -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 ( @@ -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 = ({ + )} + + )} + {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 */} { { > {/* Validation Error Display */} {Object.keys(form.formState.errors).length > 0 && ( - + { component="ul" sx={{ m: 0, pl: 2 }} > - {Object.entries(form.formState.errors).map(([field, error]) => { - const message = - typeof error === "object" && error?.message - ? error.message - : "Invalid value"; + {flattenFieldErrors(form.formState.errors).map(({ path, message }) => { + const labelKey = FIELD_LABEL_KEY[path]; + // Labels read "Name - explanation"; keep the name. + const label = labelKey ? t(labelKey).split(" - ")[0] : path; return ( -
  • +
  • - {field}: {message} + revealField(path)} + > + {label} + + {message ? `: ${message}` : ""}
  • ); diff --git a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx index 407306f1b..f6908c90b 100644 --- a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx +++ b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx @@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({