Compare commits

..
Author SHA1 Message Date
gorkem-bwl a672f7604d fix(monitors): bound certificate probe and gate client on scheme
Addresses review feedback on #3848.

Timeout: the probe now dials a user-defined port, so it can reach a
service that accepts the connection but never completes the TLS
handshake, hanging the request indefinitely. ssl-checker already wires
a "timeout" listener that rejects with "Timed Out", but its defaults
set no timeout, so it never fired. Passing one activates it.

The bundled type declaration is a hand-maintained subset of
https.RequestOptions and omitted "timeout"; it is added there so the
call typechecks against an option the library already supports.

Client gating: "http" is a single monitor type covering both schemes,
so an http:// monitor passed the existing type check and requested a
certificate anyway. With the server now rejecting those, the details
page would surface a 400 on every visit. The scheme is gated client
side to match, and the stat box falls back to "N/A" as before.

Test comment removed as requested; the timeout is covered by a new
assertion in the same file.
2026-08-25 18:14:25 -04:00
gorkem-bwl 25a7ac4877 fix(monitors): reject certificate lookups for plain-HTTP monitors
Passing the monitor's own port to ssl-checker exposed an adjacent gap found
in review: a plain-HTTP monitor on a non-default port, such as
http://host:8080, now had a TLS handshake attempted against a port that
serves no TLS. That surfaced to the client as a 500 carrying a raw OpenSSL
error ("packet length too long") instead of the previous silent "N/A".

The endpoint never checked the scheme — it was already wrong for http://
monitors, just quietly so, since every such lookup went to port 443. Guard
on the protocol and fail with a 400 before opening a socket.

Adds tests for both plain-HTTP cases (default and custom port), each
asserting the checker is never invoked. Verified by removing the guard:
both fail without it.
2026-08-25 18:14:25 -04:00
gorkem-bwl f74d1d53f6 fix(monitors): check TLS certificate on the monitor's own port
fetchMonitorCertificate parsed the monitor URL but passed only the hostname
to ssl-checker, which then defaulted to 443. A monitor on a non-standard
port such as https://host:54321 therefore had the certificate of whatever
answered on 443 inspected instead of its own, surfacing as "N/A" in the
certificate expiry box when nothing was listening there.

URL.port is an empty string for default ports, so it is mapped to 443
explicitly rather than passed through Number(), which would have yielded
NaN. ssl-checker already accepts the port via SSLOptions, so no dependency
or signature change is needed.

Adds a regression suite covering the reported case (non-standard port), the
default-port fallback, an explicit :443, and the NaN guard. Verified by
reverting the fix: the three port assertions fail without it.

Fixes #3646
2026-08-25 18:14:25 -04:00
141 changed files with 2199 additions and 6266 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+1 -1
View File
@@ -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 (non-English locales are machine translated, not managed through a translation service)
- **i18n**: i18next + react-i18next (translations via PoEditor)
---
+4 -9
View File
@@ -32,13 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br>
@@ -92,10 +91,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
The image is configured entirely through environment variables on the server container:
@@ -212,9 +207,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## 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), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) 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.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
-10
View File
@@ -11,7 +11,6 @@
"@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",
@@ -951,15 +950,6 @@
"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"
-2
View File
@@ -4,7 +4,6 @@
"version": "3.11.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
@@ -21,7 +20,6 @@
"@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",
+1 -10
View File
@@ -3,7 +3,6 @@ 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;
@@ -13,7 +12,6 @@ export type ActionMenuItem = {
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
@@ -29,14 +27,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
<div>
<IconButton
onClick={handleClick}
sx={{
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
}}
>
<IconButton onClick={handleClick}>
<Settings
size={20}
strokeWidth={1.5}
@@ -6,13 +6,12 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps {
isLoading?: boolean;
hasDateRange?: boolean;
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
dateRange: string;
setDateRange: (dateRange: string) => void;
}
export const HeaderTimeRange = ({
@@ -26,7 +25,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: DateRange | null
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
@@ -10,10 +10,8 @@ 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<number | string>;
onClick?: () => void;
}>;
@@ -21,10 +19,8 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
rightTitle,
width = "100%",
maxWidth = "100%",
flexBasis = "0%",
padding,
onClick,
}: BaseChartProps) => {
@@ -36,7 +32,6 @@ export const BaseChart = ({
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
flexBasis,
width: width,
maxWidth: maxWidth,
...(onClick && {
@@ -49,27 +44,13 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Stack
direction="row"
justifyContent={"space-between"}
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
{title}
</Typography>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -83,17 +83,12 @@ 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);
// page/section/:id routes such as docker/host/:monitorId
const isSectionDetailsPage = Boolean(
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
);
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage || isSectionDetailsPage) {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progressWithinRange]
[radius, strokeWidth, progress]
);
const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
}, 100);
return () => clearTimeout(timer);
}, [circumference, strokeLength]);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,7 +123,6 @@ export const DetailGauge = ({
lowerLabel,
lowerValue,
maxWidth = 225,
flexBasis = "0%",
}: {
title: string;
progress: number;
@@ -132,7 +131,6 @@ export const DetailGauge = ({
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
flexBasis?: number | string;
}) => {
const theme = useTheme();
return (
@@ -140,7 +138,6 @@ export const DetailGauge = ({
icon={null}
title={title}
maxWidth={maxWidth}
flexBasis={flexBasis}
>
<Stack
alignItems={"center"}
@@ -8,7 +8,7 @@ interface IconProps {
color?: string;
}
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
return (
<Icon
size={size}
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
backgroundColor: theme.palette.error.dark,
color: "#FFFFFF",
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
@@ -6,7 +6,6 @@ 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 { CONTROL_HEIGHT, ICON_CONTROL_WIDTH } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -110,7 +109,7 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
maxWidth: "108ch",
maxWidth: 640,
}}
>
{description}
@@ -142,8 +141,8 @@ export const PageHeader = ({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: ICON_CONTROL_WIDTH,
height: CONTROL_HEIGHT,
width: 36,
height: 32,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
@@ -1,5 +1,4 @@
import { Box, Stack, useTheme } from "@mui/material";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -45,7 +44,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
height: CONTROL_HEIGHT,
height: 34,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
@@ -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 { darken, lighten } from "@mui/material/styles";
import { 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,18 +11,17 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
interactive?: boolean;
}>;
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
export const GradientBox = ({ children, palette, sx }: 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 = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
const paperEnd = paper;
const bg = palette
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return (
@@ -31,12 +30,6 @@ export const GradientBox = ({ children, palette, sx, interactive }: 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,
}}
>
@@ -69,8 +62,10 @@ export const StatBox = ({
return (
<GradientBox
palette={palette}
interactive={Boolean(onClick)}
sx={{ ...(sx as object) }}
sx={{
...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
>
<Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
@@ -33,15 +33,9 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
bottom={0}
sx={{
pointerEvents: "none",
// Deliberately far fainter than `divider` - this is texture
// behind the number, not a rule, and it must never compete
// with the box's own border. Note alpha() *replaces* the
// alpha channel, so this relies on `divider` being an opaque
// hex; if it ever goes translucent this gets stronger, not
// weaker.
backgroundImage: `
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
@@ -1,19 +1,12 @@
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 {
getDockerStatePalette,
getStatusPalette,
getValuePalette,
} from "@/Utils/MonitorUtils";
import { 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];
@@ -117,36 +110,3 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
</BaseBox>
);
};
export const DockerStateLabel = ({
state,
name,
image,
}: {
state: DockerContainerState;
name: string;
image: string;
}) => {
const palette = getDockerStatePalette(state);
const theme = useTheme();
return (
<Stack
direction="row"
gap={SPACING.LG}
alignItems={"center"}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Stack>
<Typography>{name}</Typography>
<Typography variant="body2">{image}</Typography>
</Stack>
</Stack>
);
};
@@ -52,12 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>;
/**
* Marks a row selected so it gets the shared selected + hover treatment.
* Desktop table only - the small-screen card branch does not style
* selection yet, same as `getRowSx`.
*/
isRowSelected?: (row: T) => boolean;
};
export function DataTable<
@@ -76,7 +70,6 @@ export function DataTable<
emptyViewText,
emptyViewPositive,
getRowSx,
isRowSelected,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -86,7 +79,6 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
return (
@@ -116,11 +108,6 @@ 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}
>
@@ -204,7 +191,7 @@ export function DataTable<
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 600,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -220,17 +207,6 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
"& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": {
backgroundColor: theme.palette.action.selected,
},
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.rowHover,
},
"& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.selectedHover,
},
}}
>
<TableHead>
@@ -255,14 +231,8 @@ export function DataTable<
return (
<Fragment key={key}>
<TableRow
className={[
isInteractive ? "is-clickable" : "",
isRowSelected?.(row) ? "is-selected" : "",
]
.filter(Boolean)
.join(" ")}
sx={{
cursor: isInteractive ? "pointer" : "default",
cursor: onRowClick ? "pointer" : "default",
...(getRowSx?.(row) as object),
}}
onClick={() => {
@@ -322,12 +292,6 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
});
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
@@ -357,7 +321,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -375,7 +338,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -393,7 +355,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -411,7 +372,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsLeft
@@ -459,7 +419,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -477,7 +436,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -495,7 +453,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -513,7 +470,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
<IconButton
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
<Ellipsis
size={20}
@@ -1,8 +1,6 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
@@ -10,7 +8,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
<MuiTabs
sx={{
minHeight: CONTROL_HEIGHT,
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
@@ -40,9 +38,9 @@ export const Tab = (props: CustomTabProps) => {
iconPosition="start"
sx={{
textTransform: "none",
fontSize: typographyLevels.m,
fontSize: 14,
fontWeight: 500,
minHeight: CONTROL_HEIGHT,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
@@ -50,6 +48,7 @@ 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,
+2 -3
View File
@@ -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 { CONTROL_HEIGHT, HOVER } from "@/Utils/Theme/constants";
import { HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
@@ -43,8 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
height: CONTROL_HEIGHT,
lineHeight: 1.2,
height: 34,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
+1 -2
View File
@@ -4,7 +4,6 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -19,7 +18,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: CONTROL_HEIGHT,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
+2 -5
View File
@@ -8,7 +8,6 @@ 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 { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -56,7 +55,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
minHeight: CONTROL_HEIGHT,
minHeight: 34,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
@@ -67,9 +66,7 @@ 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.text.disabled,
borderColor: error ? theme.palette.error.main : theme.palette.divider,
},
},
},
+5 -2
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
@@ -72,7 +71,7 @@ const SelectInputInner = <T,>(
/>
)}
sx={{
height: `${CONTROL_HEIGHT}px`,
height: "34px",
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
@@ -84,6 +83,10 @@ const SelectInputInner = <T,>(
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
...props.sx,
}}
+9 -3
View File
@@ -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 { CONTROL_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
import { LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
@@ -22,10 +22,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : CONTROL_HEIGHT,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
@@ -33,7 +39,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(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.paper} inset !important`,
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
+1 -2
View File
@@ -7,7 +7,6 @@ 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 { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -34,7 +33,7 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
minHeight: CONTROL_HEIGHT,
minHeight: 34,
p: 0,
px: theme.spacing(5),
},
@@ -4,7 +4,6 @@ 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 { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -21,7 +20,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
height: CONTROL_HEIGHT,
height: 34,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -35,6 +34,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
fontWeight: 500,
"&:hover": {
backgroundColor: selectedHoverBg,
},
@@ -9,11 +9,12 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -0,0 +1,34 @@
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",
};
@@ -1,113 +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",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -1,70 +0,0 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -12,7 +12,85 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({
dateRange,
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+52 -78
View File
@@ -19,94 +19,68 @@ import {
Users,
Tag,
Waypoints,
Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
},
],
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
group: t("components.sidebar.menu.groups.alerting"),
items: [
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
],
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
{
group: t("components.sidebar.menu.groups.reporting"),
items: [
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
],
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
group: t("components.sidebar.menu.groups.configuration"),
items: [
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.settings"),
path: "settings",
icon: <Icon icon={Settings} />,
},
],
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
{
name: t("components.sidebar.menu.settings"),
icon: <Icon icon={Settings} />,
path: "settings",
},
];
};
+2 -2
View File
@@ -28,8 +28,8 @@ export const NavItem = ({
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
? theme.palette.action.selectedHover
: theme.palette.action.rowHover;
? theme.palette.action.selected
: theme.palette.action.hover;
const fontWeight = selected ? 600 : 400;
return (
<Tooltip
+13 -51
View File
@@ -1,12 +1,9 @@
import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -32,7 +29,7 @@ export const Sidebar = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { width, transition, transitionTiming, collapsed } = useSidebar();
const { width, transition, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
@@ -73,11 +70,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)",
}}
>
<List
@@ -92,52 +89,17 @@ export const Sidebar = () => {
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
{menu.map((group) => (
<Box key={group.group}>
<Box
position="relative"
overflow="hidden"
>
<Typography
variant="eyebrow"
component="div"
color={theme.palette.text.disabled}
fontSize={typographyLevels.s}
paddingLeft={theme.spacing(5)}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.XXS)}
whiteSpace="nowrap"
sx={{
opacity: collapsed ? 0 : 1,
transition: `opacity ${transitionTiming}`,
}}
>
{group.group}
</Typography>
<Divider
sx={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
opacity: collapsed ? 1 : 0,
transition: `opacity ${transitionTiming}`,
}}
/>
</Box>
{group.items.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</Box>
))}
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</List>
<StarPrompt />
+1 -16
View File
@@ -2,14 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed"
| "docker";
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
interface TableState {
rowsPerPage: number;
@@ -21,11 +14,9 @@ interface SidebarState {
interface UIState {
monitors: TableState;
pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -46,9 +37,6 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
pagespeed: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
@@ -58,9 +46,6 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
docker: {
rowsPerPage: 5,
},
logs: {
rowsPerPage: 15,
},
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+1 -3
View File
@@ -6,8 +6,7 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
export const useSidebar = () => {
const collapsed = useSelector(
@@ -20,7 +19,6 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION,
transitionTiming: SIDEBAR_TRANSITION_TIMING,
};
};
@@ -59,12 +59,7 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={
<Icon
icon={Mail}
size={16}
/>
}
startIcon={<Icon icon={Mail} />}
onClick={onInviteClick}
>
{t("common.buttons.inviteMember")}
@@ -74,12 +69,7 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={
<Icon
icon={UserPlus}
size={16}
/>
}
startIcon={<Icon icon={UserPlus} />}
onClick={onAddMemberClick}
>
{t("common.buttons.addMember")}
+1 -2
View File
@@ -17,14 +17,13 @@ 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<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
Whitespace-only changes.
@@ -1,51 +0,0 @@
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 (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
subtitle={health}
/>
</Stack>
);
};
@@ -1,3 +0,0 @@
export const TabLogs = () => {
return "This is the logs tab";
};
@@ -1,113 +0,0 @@
// 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 (
<Grid
container
spacing={LAYOUT.MD}
>
{getChartConfigs(theme, t, stats).map((config) => {
return (
<Grid
key={config.key}
size={isSmall ? 12 : 6}
>
<HistogramDockerContainer
title={config.title}
rightTitle={config.rightTitle}
stats={stats}
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
/>
</Grid>
);
})}
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Ports
</Typography>
<Stack>
{dedupedPorts?.map((port) => {
return (
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Volumes
</Typography>
<Stack>
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
</Grid>
);
};
@@ -1,78 +0,0 @@
// 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 = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("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<DockerContainerResponse>(
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 (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes container={container} />
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && <TabLogs />}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -1,123 +0,0 @@
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) => (
// <Box
// width={16}
// display="inline-flex"
// justifyContent="center"
// >
// {isActive ? (
// sortOrder === "asc" ? (
// <ArrowUp size={16} />
// ) : (
// <ArrowDown size={16} />
// )
// ) : null}
// </Box>
// );
const headers: Header<DockerContainerInfo>[] = [
{
id: "container",
content: t("pages.docker.host.table.headers.container"),
render: (row) => {
return (
<DockerStateLabel
state={row.state}
name={row.name}
image={row.image}
/>
);
},
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
return <Typography>{row.state}</Typography>;
},
},
{
id: "health",
content: t("common.labels.health"),
render: (row) => {
return <Typography>{row.health}</Typography>;
},
},
{
id: "cpu",
content: t("pages.docker.host.table.headers.cpu"),
render: (row) => {
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
},
},
{
id: "memory",
content: t("pages.docker.host.table.headers.memory"),
render: (row) => {
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
},
},
{
id: "restarts",
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={containers}
onRowClick={(row) => {
navigate(`/docker/host/${monitorId}/container/${row.name}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
// ? theme.palette.action.selected
// : "inherit",
// })}
/>
</Box>
);
};
@@ -1,47 +0,0 @@
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 (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("pages.docker.host.statBoxes.containers")}
subtitle={String(total)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.running")}
subtitle={String(running)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.stopped")}
subtitle={String(stopped)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.unhealthy")}
subtitle={String(unhealthy)}
/>
</Stack>
);
};
@@ -1,55 +0,0 @@
import { BasePage } from "@/Components/design-elements";
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
// Types
import type { DockerDetailsResponse } from "@/Types/Monitor";
// Hooks
import { useGet } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useParams } from "react-router-dom";
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
const DockerHostDetailsPage = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const isAdmin = useIsAdmin();
// The containers list renders the latest check only, so no date range is
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
const { data: monitorDetailsData, refetch: refetchMonitor } =
useGet<DockerDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = monitorDetailsData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const stats = monitorDetailsData?.stats;
const containers = stats?.latest?.containers ?? [];
return (
<BasePage>
<HeaderMonitorControls
path="docker"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetchMonitor}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable
monitorId={monitorId}
containers={containers}
/>
</BasePage>
);
};
export default DockerHostDetailsPage;
@@ -1,343 +0,0 @@
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<Record<string, never>, 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<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
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: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
action: () => {
setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
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: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("pages.docker.table.headers.host")}
{renderSortIcon(sortField === "name")}
</Typography>
),
render: (row) => {
return (
<Stack>
<Typography variant="body2">{row.name}</Typography>
<Typography
variant="caption"
color={theme.palette.text.secondary}
>
{row.url}
</Typography>
</Stack>
);
},
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
),
render: (row) => {
return <StatusLabel status={row.status} />;
},
},
{
id: "containers",
content: t("pages.docker.table.headers.containers"),
render: (row) => {
const summary = summaryOf(row);
return (
<Typography variant="body2">
{summary
? t("pages.docker.table.containersRunning", {
running: summary.running,
total: summary.total,
})
: "—"}
</Typography>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tagId) => {
const fullTag = tags.find((t) => t.id === tagId);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/docker/host/${row.id}`);
}}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
};
@@ -1,44 +0,0 @@
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 (
<MonitorListPage
headerKey="docker"
page="docker"
actionLink="/docker/create"
controller={c}
bulkActions
showTypeFilter={false}
>
<DockerMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/>
</MonitorListPage>
);
};
export default DockerMonitorsPage;
@@ -30,10 +30,6 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
direction="row"
alignItems="center"
gap={theme.spacing(2)}
// The icons are supporting marks beside the label, not content,
// so they take secondary ink rather than lucide's currentColor
// default, which renders them heavier than the text.
sx={{ "& svg": { color: theme.palette.text.secondary } }}
>
{icon}
<Typography variant="body1">{label}</Typography>
@@ -147,10 +143,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
<Icon
icon={Globe}
color={theme.palette.text.secondary}
/>
<Icon icon={Globe} />
<Typography
variant="body1"
fontWeight={500}
+1 -2
View File
@@ -20,7 +20,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import type { DateRange } from "@/Types/Query";
import { useTheme } from "@mui/material";
const IncidentsPage = () => {
@@ -31,7 +30,7 @@ const IncidentsPage = () => {
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
title: t("common.charts.labels.memoryUsage"),
title: t("pages.infrastructure.charts.labels.memory"),
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("common.charts.labels.cpuUsage"),
title: t("pages.infrastructure.charts.labels.cpu"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
@@ -63,42 +63,19 @@ const getChartConfigs = (
return configs;
};
// Loopback / bridge names Capture reports when it can only observe the
// container's own network namespace.
// 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.
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<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) {
return false;
if (named.size === 0) return false;
for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
}
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));
return true;
};
export const InfraNetworkCharts = ({
@@ -8,8 +8,6 @@ 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,
}: {
@@ -28,42 +26,37 @@ export const InfraDetailsGauges = ({
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
flexWrap={"wrap"}
useFlexGap
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={GAUGE_MAX_WIDTH}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={GAUGE_MAX_WIDTH}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
{snapshot?.disk?.map((disk, idx) => {
return (
<DetailGauge
key={disk?.device || `disk-${idx}`}
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={GAUGE_MAX_WIDTH}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={prettyBytes(disk?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/>
);
})}
@@ -10,7 +10,6 @@ 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();
@@ -18,7 +17,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => {
@@ -331,7 +331,11 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
isRowSelected={(row) => isRowSelected(row.id)}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const c = useMonitorListController({
types: ["hardware"],
checksLimit: 1,
refreshInterval: 5000,
rowsPerPageTable: "infrastructure",
rowsPerPageDefault: 5,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure"
actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<InfraMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+1 -24
View File
@@ -1,8 +1,7 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { useTheme } from "@mui/material";
@@ -16,7 +15,6 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -43,7 +41,6 @@ const CreateMaintenanceWindowPage = () => {
);
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch();
@@ -206,26 +203,6 @@ const CreateMaintenanceWindowPage = () => {
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>{monitor.name}</Typography>
{monitor.tags.map((tagId) => {
const tag = tags?.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
/>
}
/>
+1 -2
View File
@@ -15,13 +15,12 @@ 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<DateRange>("day");
const [dateRange, setDateRange] = useState<string>("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
} = useGet<AppSettingsResponse>("/settings");
} = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({
types: ["pagespeed"],
checksLimit: 25,
refreshInterval: 30000,
rowsPerPageTable: "pagespeed",
rowsPerPageDefault: 10,
initialSortField: "name",
});
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const [sortField, setSortField] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed"
actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
>
<PageSpeedMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
<HeaderCreate
path="/pagespeed/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
File diff suppressed because it is too large. Load diff
@@ -25,12 +25,7 @@ export const HeaderConfigStatusControls = ({
<Button
variant="contained"
color="error"
startIcon={
<Icon
icon={Trash}
size={16}
/>
}
startIcon={<Icon icon={Trash} />}
onClick={onDelete}
>
{translate.t("common.buttons.delete")}
@@ -77,12 +77,7 @@ export const HeaderStatusPageControls = ({
<Button
variant="contained"
color="secondary"
startIcon={
<Icon
icon={Settings}
size={16}
/>
}
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
@@ -113,7 +113,6 @@ const StatusPageView = () => {
apiUrl,
{},
{
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
}
);
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -34,9 +34,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -327,12 +327,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "3px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -347,11 +344,8 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
@@ -38,9 +38,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -272,11 +272,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -288,11 +285,8 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
@@ -1,6 +1,6 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -37,9 +37,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -83,14 +83,13 @@ export const modernStyles = (
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
empty: tokens.border,
};
@@ -104,7 +103,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
};
return {
@@ -355,19 +354,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind, severity = 1) => {
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
return {
borderRadius: "3px",
background:
kind === "degraded"
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
@@ -377,11 +370,8 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
@@ -37,9 +37,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -296,12 +296,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "2px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +312,8 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
@@ -59,9 +59,9 @@ export interface BaseStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -211,7 +211,6 @@ export const BaseStatusPage = ({
>
<Icon
icon={TriangleAlert}
size={20}
color={theme.palette.warning.main}
/>
<Typography>
@@ -18,7 +18,6 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
export interface ChartCell {
key: string;
severity: number;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
@@ -29,22 +28,9 @@ export interface ChartCell {
const DAY_DOWN_THRESHOLD = 0.5;
// Color interpolation
// 0 = perfect
// 1 = at or below the down threshold
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
export const severityFromUpRatio = (upRatio: number): number => {
const failed = 1 - upRatio;
if (failed <= 0) return 0;
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
const clamped = Math.min(1, Math.max(0, scaled));
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
};
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
if (bucket.totalChecks === 0) return "empty";
const upRatio = bucket.upChecks / bucket.totalChecks;
if (upRatio >= 1) return "up";
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
return "degraded";
@@ -69,7 +55,6 @@ const emptyCell = (key: string): ChartCell => ({
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -91,7 +76,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
responseTime: check.responseTime ?? 0,
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
@@ -109,17 +93,9 @@ export const dailyBucketsToCells = (
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
const barKind = classifyDay(bucket);
return {
key: `day-${date}`,
severity:
barKind === "degraded"
? severityFromUpRatio(upRatio)
: barKind === "down"
? 1
: 0,
barKind,
heatKind:
barKind === "up"
@@ -10,7 +10,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
}
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
placement="top"
>
<Box
sx={cellSx(cell.heatKind, cell.severity)}
sx={cellSx(cell.heatKind)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -13,7 +13,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
sx={barSx(cell.barKind, cell.heightPct)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -1,8 +1,5 @@
// No "Inter" here: its webfont was never self-hosted, so once the Google Fonts
// import went it resolved to a fallback anyway. Listing it only made the stack
// look intentional on machines that happen to have Inter installed.
export const SANS_STACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
editorial,
minimal,
};
// Interpolate between a theme's up and degraded colors by severity (0..1).
export const mixSeverityColor = (
up: string,
degraded: string,
severity: number
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
+17 -5
View File
@@ -31,10 +31,20 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
import type { DateRange } from "@/Types/Query";
const certificateDateFormat = "MMM D, YYYY h A";
const isHttpsUrl = (url?: string): boolean => {
if (!url) {
return false;
}
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
};
interface CertificateResponse {
certificateDate: string;
}
@@ -54,7 +64,7 @@ const UptimeDetailsPage = () => {
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
@@ -81,13 +91,15 @@ const UptimeDetailsPage = () => {
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
// Certificate fetch - only for HTTP monitors
// Certificate fetch - only for monitors served over HTTPS. A "http" type
// monitor may still have an http:// URL, which serves no certificate, so the
// scheme is gated here to match the server rather than requesting a 400.
const certificateUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http") {
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
return null;
}
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
}, [monitorId, monitor?.type, monitor?.url]);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
@@ -350,7 +350,11 @@ export const MonitorTable = ({
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
isRowSelected={(row) => isRowSelected(row.id)}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
+280 -29
View File
@@ -1,43 +1,294 @@
import { MonitorListPage } from "@/Components/monitors";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import Typography from "@mui/material/Typography";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { SPACING } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const c = useMonitorListController({
types: "selectable",
checksLimit: 25,
refreshInterval: 5000,
rowsPerPageTable: "monitors",
rowsPerPageDefault: 10,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
);
// Local state
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const {
monitors: monitorsWithChecks,
summary,
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
// Handlers
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="uptime"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="uptime"
actionLink="/uptime/create"
controller={c}
bulkActions
>
<MonitorTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/uptime/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<MonitorTable
monitors={monitorsWithChecks || []}
tags={tags || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setRowsPerPage={(rowsPerPage: number) => {
dispatch(
setRowsPerPage({
value: rowsPerPage,
table: "monitors",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
-25
View File
@@ -20,11 +20,6 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
import Infrastructure from "@/Pages/Infrastructure/Monitors";
import InfrastructureDetails from "@/Pages/Infrastructure/Details";
// Docker
import Docker from "@/Pages/Docker/Monitors";
import DockerHostDetails from "@/Pages/Docker/HostDetails";
import DockerDetails from "@/Pages/Docker/ContainerDetails";
// Checks
import Checks from "@/Pages/Checks";
@@ -144,26 +139,6 @@ const Routes = () => {
path="infrastructure/:monitorId"
element={<InfrastructureDetails />}
/>
<Route
path="docker"
element={<Docker />}
/>
<Route
path="docker/create"
element={<CreateMonitor />}
/>
<Route
path="/docker/configure/:monitorId"
element={<CreateMonitor />}
/>
<Route
path="docker/host/:monitorId/container/:containerName"
element={<DockerDetails />}
/>
<Route
path="docker/host/:monitorId"
element={<DockerHostDetails />}
/>
<Route
path="checks/:monitorId?"
element={<Checks />}
-61
View File
@@ -119,65 +119,6 @@ export interface CheckTimings {
};
}
// Mirrors DockerContainerSummary in server/src/types/network.ts.
export interface DockerContainerSummary {
total: number;
running: number;
stopped: number;
unhealthy: number;
}
// Mirrors DockerContainerStates in server/src/types/network.ts.
export const DockerContainerStates = [
"created",
"running",
"paused",
"restarting",
"removing",
"exited",
"dead",
] as const;
export type DockerContainerState = (typeof DockerContainerStates)[number];
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
export interface DockerContainerPort {
privatePort: number;
protocol: DockerPortProtocol;
publicPort?: number;
hostIp?: string;
}
export interface DockerContainerMount {
type: string;
name?: string;
source: string;
destination: string;
mode: string;
rw: boolean;
}
export interface DockerContainerInfo {
id: string;
name: string;
image: string;
state: DockerContainerState;
status: string;
health: DockerHealthStatus;
cpuPct?: number;
memoryUsedBytes?: number;
memoryLimitBytes?: number;
memoryPct?: number;
restartCount?: number;
startedAt?: string;
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
}
export interface Check {
id: string;
metadata: CheckMetadata;
@@ -192,7 +133,6 @@ export interface Check {
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
@@ -347,7 +287,6 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
containerSummary?: DockerContainerSummary;
};
export interface HasResponseTime {
responseTime: number;
+1 -54
View File
@@ -3,8 +3,6 @@ import type {
CheckSnapshot,
GroupedUptimeCheck,
DailyCheckBucket,
DockerContainerInfo,
DockerContainerSummary,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
@@ -32,6 +30,7 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const SelectableMonitorTypes = [
"http",
"ping",
"docker",
"port",
"game",
"grpc",
@@ -270,58 +269,6 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
export interface DockerStatsBucket {
_id: string;
avgResponseTime: number | null;
upCount: number;
totalCount: number;
avgRunning: number | null;
avgTotal: number | null;
avgUnhealthy: number | null;
}
export interface DockerStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
aggregate: DockerStatsBucket[];
latest: {
containers: DockerContainerInfo[];
summary?: DockerContainerSummary;
checkedAt: string;
} | null;
}
export interface DockerDetailsResponse {
monitor: Monitor;
stats: DockerStats;
monitorStats: MonitorStats | null;
}
export interface DockerContainerStatsBucket {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
}
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
export interface DockerContainerResponse {
monitor: Monitor;
stats: DockerContainerStats;
}
export interface Game {
name: string;
options?: {
-2
View File
@@ -1,2 +0,0 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
+1 -48
View File
@@ -1,11 +1,6 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import type {
DockerContainerMount,
DockerContainerPort,
DockerContainerState,
} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
@@ -17,7 +12,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "docker",
docker: "uptime",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
@@ -46,19 +41,6 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value];
};
export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
const paletteMap: Record<DockerContainerState, PaletteKey> = {
paused: "warning",
created: "warning",
running: "success",
restarting: "warning",
removing: "warning",
exited: "error",
dead: "error",
};
return paletteMap[state];
};
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === "up") {
return theme.palette.success.light;
@@ -115,32 +97,3 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl;
};
export const dedupeDockerPorts = (
ports: DockerContainerPort[]
): DockerContainerPort[] => {
return ports.filter((port) => {
// Discard ipv6 wildcards
if (port.hostIp !== "::") return port;
const isDuplicate = ports.some((otherPort) => {
if (otherPort.hostIp !== "0.0.0.0") return false;
return (
otherPort.privatePort === port.privatePort &&
otherPort.protocol === port.protocol &&
otherPort.publicPort === port.publicPort
);
});
return !isDuplicate;
});
};
const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.type === "volume" && mount.name)
return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
if (mount.source) return mount.source;
return mount.type;
};
-62
View File
@@ -1,5 +1,4 @@
import { alpha } from "@mui/material/styles";
import { HOVER } from "@/Utils/Theme/constants";
const typographyBase = 13;
@@ -18,20 +17,6 @@ export const colors = {
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
ink900: "#101917",
ink600: "#55635C",
ink400: "#7C8983",
surface50: "#F6F8F7",
rule200: "#C7D2CC",
successMain: "#0D6049",
errorMain: "#A02B24",
warningMain: "#8C5806",
inkDark900: "#ECEFED",
inkDark600: "#A3ADA8",
ruleDark: "#3A4642",
successDark: "#4BC49E",
errorDark: "#E8837A",
warningDark: "#E0A458",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
@@ -54,34 +39,9 @@ export const lightPalette = {
primary: {
main: colors.brandGreen,
},
action: {
rowHover: alpha(colors.gray900, HOVER.ROW),
controlHover: alpha(colors.gray900, HOVER.CONTROL),
selected: alpha(colors.brandGreen, 0.06),
selectedHover: alpha(colors.brandGreen, 0.1),
},
secondary: {
main: colors.gray200,
},
background: {
default: colors.surface50,
paper: "#FFFFFF",
},
text: {
primary: colors.ink900,
secondary: colors.ink600,
disabled: colors.ink400,
},
divider: colors.rule200,
success: {
main: colors.successMain,
},
error: {
main: colors.errorMain,
},
warning: {
main: colors.warningMain,
},
sidebar: {
accent: colors.brandGreen,
},
@@ -104,13 +64,6 @@ export const lightPalette = {
export const darkPalette = {
primary: {
main: colors.brandGreen,
light: colors.brandGreenLight,
},
action: {
rowHover: alpha("#FFFFFF", HOVER.ROW),
controlHover: alpha("#FFFFFF", HOVER.CONTROL),
selected: alpha(colors.brandGreenLight, 0.07),
selectedHover: alpha(colors.brandGreenLight, 0.11),
},
secondary: {
main: colors.gray700,
@@ -119,21 +72,6 @@ export const darkPalette = {
default: colors.gray900,
paper: colors.gray850,
},
text: {
primary: colors.inkDark900,
secondary: colors.inkDark600,
disabled: colors.ink400,
},
divider: colors.ruleDark,
success: {
main: colors.successDark,
},
error: {
main: colors.errorDark,
},
warning: {
main: colors.warningDark,
},
sidebar: {
accent: colors.brandGreenLight,
},
+9 -41
View File
@@ -13,11 +13,6 @@ declare module "@mui/material/styles" {
eyebrow?: React.CSSProperties;
fontFamilyMonospace?: string;
}
interface TypeAction {
rowHover: string;
controlHover: string;
selectedHover: string;
}
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
@@ -58,7 +53,7 @@ export type PaletteKey = {
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
}[keyof Theme["palette"]];
const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif';
const fontFamilyPrimary = "system-ui, sans-serif";
const fontFamilyMonospace =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
const shadow =
@@ -86,13 +81,11 @@ export const theme = (mode: string, palette: any) =>
fontSize: typographyLevels.base,
h1: {
fontSize: typographyLevels.xxl,
fontWeight: 700,
letterSpacing: "-0.015em",
fontWeight: 500,
},
h2: {
fontSize: typographyLevels.l,
fontWeight: 600,
letterSpacing: "-0.01em",
fontWeight: 400,
},
body1: {
fontSize: typographyLevels.m,
@@ -134,11 +127,7 @@ export const theme = (mode: string, palette: any) =>
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-focusVisible": {
outline: `1px solid ${
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.main
}`,
outline: `1px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
},
}),
@@ -195,36 +184,15 @@ export const theme = (mode: string, palette: any) =>
MuiOutlinedInput: {
styleOverrides: {
input: {
paddingTop: 0,
paddingBottom: 0,
height: "100%",
},
root: ({ theme }) => ({
"& .MuiSelect-select": {
paddingTop: 0,
paddingBottom: 0,
display: "flex",
alignItems: "center",
minHeight: "unset",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
transition: "border-color 0.15s ease",
},
"&:hover:not(.Mui-focused):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline":
{
borderColor: theme.palette.text.disabled,
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor:
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.main,
borderWidth: 2,
? "rgba(255, 255, 255, 0.23)"
: theme.palette.text.primary,
},
"&.Mui-error .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.error.main,
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: palette.primary.main,
},
}),
},
-16
View File
@@ -18,22 +18,6 @@ export const LAYOUT = {
XXL: 16,
} as const;
/**
* Shared height for anything that can sit in a row with something else -
* buttons, inputs, selects, pickers, toggles, tabs, icon buttons. One number so
* they share a baseline instead of drifting 2px apart.
*/
export const CONTROL_HEIGHT = 32;
/**
* Width of a square-ish icon-only control that sits in a header strip - wider
* than CONTROL_HEIGHT so the icon has breathing room either side of the
* dividers between them.
*/
export const ICON_CONTROL_WIDTH = 36;
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards
CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row
} as const;
+1 -6
View File
@@ -69,12 +69,7 @@ const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
proxyMode: z.enum(ProxyModes),
// The form uses "" for "no proxy selected". drop it from the payload so the
// server never receives an empty ObjectId
proxyId: z
.string()
.transform((value) => (value === "" ? undefined : value))
.optional(),
proxyId: z.string().optional(),
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
+2
View File
@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
* {
box-sizing: border-box;
}
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "صفحات الحالة",
"maintenance": "الصيانة",
"logs": "السجلات",
"settings": "الإعدادات",
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
"reporting": "التقارير",
"configuration": "الإعدادات العامة"
}
"settings": "الإعدادات"
},
"bottomMenu": {
"support": "الدعم",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "عام",
"monitoring": "المراقبة",
"email": "البريد الإلكتروني",
"integrations": "التكاملات",
"data": "البيانات"
},
"form": {
"timezone": {
"title": "المنطقة الزمنية للعرض",
+1 -14
View File
@@ -189,13 +189,7 @@
"statusPages": "Pàgines d'estat",
"maintenance": "Manteniment",
"logs": "Registres",
"settings": "Configuració",
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
"reporting": "Informes",
"configuration": "Configuració"
}
"settings": "Configuració"
},
"bottomMenu": {
"support": "Suport",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoratge",
"email": "Correu electrònic",
"integrations": "Integracions",
"data": "Dades"
},
"form": {
"timezone": {
"title": "Zona horària de visualització",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Stavové stránky",
"maintenance": "Údržba",
"logs": "Záznamy",
"settings": "Nastavení",
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
"reporting": "Přehledy",
"configuration": "Konfigurace"
}
"settings": "Nastavení"
},
"bottomMenu": {
"support": "Podpora",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "Obecné",
"monitoring": "Monitorování",
"email": "E-mail",
"integrations": "Integrace",
"data": "Data"
},
"form": {
"timezone": {
"title": "Zobrazované časové pásmo",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Statusseite",
"maintenance": "Wartung",
"logs": "Protokolle",
"settings": "Einstellungen",
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
"reporting": "Berichte",
"configuration": "Konfiguration"
}
"settings": "Einstellungen"
},
"bottomMenu": {
"support": "Support",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "Allgemein",
"monitoring": "Überwachung",
"email": "E-Mail",
"integrations": "Integrationen",
"data": "Daten"
},
"form": {
"timezone": {
"title": "Anzeigesprache Zeitzone",
+7 -68
View File
@@ -66,11 +66,9 @@
"charts": {
"labels": {
"averageResponseTime": "Average response time",
"cpuUsage": "CPU usage",
"downtime": "Downtime",
"high": "High",
"low": "Low",
"memoryUsage": "Memory usage",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
@@ -104,11 +102,7 @@
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time",
"state": "State",
"uptime": "Uptime",
"health": "Health",
"restarts": "Restarts"
"responseTime": "Response time"
},
"form": {
"autocomplete": {
@@ -209,7 +203,6 @@
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
"docker": "Docker",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
@@ -218,13 +211,7 @@
"statusPages": "Status pages",
"maintenance": "Maintenance",
"logs": "Logs",
"settings": "Settings",
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
}
"settings": "Settings"
},
"bottomMenu": {
"support": "Support",
@@ -931,56 +918,15 @@
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
}
},
"docker": {
"header": {
"title": "Docker monitors",
"description": "Monitor your Docker hosts and every container running on them."
},
"fallback": {
"title": "No Docker monitors yet",
"description": "Add a Docker host to monitor its containers.",
"actionButton": "Create a monitor"
},
"table": {
"headers": {
"host": "Host",
"containers": "Containers"
},
"containersRunning": "{{running}} of {{total}} running"
},
"container": {
"header": {
"title": "Container details",
"description": "State, resource usage, and logs for this container."
},
"tabs": {
"overview": "Overview",
"logs": "Logs"
}
},
"host": {
"statBoxes": {
"containers": "Containers",
"running": "Running",
"stopped": "Stopped",
"unhealthy": "Unhealthy"
},
"table": {
"headers": {
"container": "Containers",
"cpu": "CPU",
"memory": "Memory"
}
}
}
},
"infrastructure": {
"charts": {
"labels": {
"cpu": "CPU usage",
"disk": "Disk usage",
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
"temp": "CPU temperature"
}
},
@@ -1387,13 +1333,6 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoring",
"email": "Email",
"integrations": "Integrations",
"data": "Data"
},
"form": {
"timezone": {
"title": "Display timezone",
@@ -1451,7 +1390,7 @@
}
},
"url": {
"title": "Monitor IP/URL on status page",
"title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
"option": {
"showURL": {
@@ -1483,7 +1422,7 @@
}
},
"thresholds": {
"title": "Global thresholds",
"title": "Global Thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
"option": {
"cpu": {
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Páginas de estado",
"maintenance": "Mantenimiento",
"logs": "Registros",
"settings": "Configuración",
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
"reporting": "Informes",
"configuration": "Configuración"
}
"settings": "Configuración"
},
"bottomMenu": {
"support": "Soporte",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "General",
"monitoring": "Monitorización",
"email": "Correo electrónico",
"integrations": "Integraciones",
"data": "Datos"
},
"form": {
"timezone": {
"title": "Zona horaria de visualización",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Tilasivut",
"maintenance": "Huolto",
"logs": "Lokit",
"settings": "Asetukset",
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
"reporting": "Raportointi",
"configuration": "Määritykset"
}
"settings": "Asetukset"
},
"bottomMenu": {
"support": "Tuki",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "Yleiset",
"monitoring": "Valvonta",
"email": "Sähköposti",
"integrations": "Integraatiot",
"data": "Tiedot"
},
"form": {
"timezone": {
"title": "Näytettävä aikavyöhyke",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Pages de statut",
"maintenance": "Maintenance",
"logs": "Journaux",
"settings": "Paramètres",
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
"reporting": "Rapports",
"configuration": "Configuration"
}
"settings": "Paramètres"
},
"bottomMenu": {
"support": "Support",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "Général",
"monitoring": "Surveillance",
"email": "E-mail",
"integrations": "Intégrations",
"data": "Données"
},
"form": {
"timezone": {
"title": "Fuseau horaire d'affichage",
+1 -14
View File
@@ -193,13 +193,7 @@
"statusPages": "Pagine di stato",
"maintenance": "Manutenzione",
"logs": "Log",
"settings": "Impostazioni",
"groups": {
"monitoring": "Monitoraggio",
"alerting": "Avvisi",
"reporting": "Report",
"configuration": "Configurazione"
}
"settings": "Impostazioni"
},
"bottomMenu": {
"support": "Supporto",
@@ -1243,13 +1237,6 @@
}
},
"settings": {
"tabs": {
"general": "Generale",
"monitoring": "Monitoraggio",
"email": "Email",
"integrations": "Integrazioni",
"data": "Dati"
},
"form": {
"timezone": {
"title": "Fuso orario di visualizzazione",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "ステータスページ",
"maintenance": "メンテナンス",
"logs": "ログ",
"settings": "設定",
"groups": {
"monitoring": "監視",
"alerting": "アラート",
"reporting": "レポート",
"configuration": "設定"
}
"settings": "設定"
},
"bottomMenu": {
"support": "サポート",
@@ -1147,13 +1141,6 @@
}
},
"settings": {
"tabs": {
"general": "一般",
"monitoring": "監視",
"email": "メール",
"integrations": "連携",
"data": "データ"
},
"form": {
"timezone": {
"title": "表示タイムゾーン",
+1 -14
View File
@@ -214,13 +214,7 @@
"settings": "Ustawienia",
"statusPages": "Strony statusu",
"tags": "Tagi",
"uptime": "Czas działania",
"groups": {
"monitoring": "Monitorowanie",
"alerting": "Alerty",
"reporting": "Raporty",
"configuration": "Konfiguracja"
}
"uptime": "Czas działania"
},
"starPrompt": {
"description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub",
@@ -1213,13 +1207,6 @@
}
},
"settings": {
"tabs": {
"general": "Ogólne",
"monitoring": "Monitorowanie",
"email": "E-mail",
"integrations": "Integracje",
"data": "Dane"
},
"form": {
"about": {
"developedBy": "Opracowane przez Bluewave Labs",
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more