Compare commits

..
Author SHA1 Message Date
Alex Holliday 35c022df0f geo job 2026-02-23 00:00:45 +00:00
Alex Holliday 19fc85ae2d front end 2026-02-22 23:25:04 +00:00
Alex Holliday b4f8fa7d72 aggregation 2026-02-22 21:15:02 +00:00
Alex Holliday ef5629d8e4 remove unused 2026-02-22 20:35:13 +00:00
Alex Holliday 4018125224 initial commit 2026-02-22 20:28:42 +00:00
254 changed files with 4867 additions and 16260 deletions

No files matched your search

+1 -5
View File
@@ -3,10 +3,6 @@
.VSCodeCounter
*.sh
mongo
timescaledb
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
docs/timescale
docs/reviews
+17 -14
View File
@@ -42,19 +42,15 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## User's guide
Usage instructions can be found [here](https://checkmate.so/docs).
## Prerequisites
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). It's still WIP and some of the information there might be outdated as we continuously add features weekly. Rest assured, we are doing our best! :)
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
@@ -84,7 +80,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -161,20 +157,27 @@ Here's how you can contribute:
0. Star this repo :)
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
2. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
3. Open an issue if you believe you've encountered a bug.
4. Check for good-first-issue's if you are a newcomer.
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
6. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
2. Check [project structure](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
3. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
4. Open an issue if you believe you've encountered a bug.
5. Check for good-first-issue's if you are a newcomer.
6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
7. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
</a>
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
## Our sponsors
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
Also check other developer and contributor-friendly projects of BlueWave:
- [VerifyWise](https://github.com/bluewave-labs/verifywise), the first open source AI governance platform.
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
+123 -690
View File
File diff suppressed because it is too large. Load diff
+3 -6
View File
@@ -13,7 +13,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "npm run format"
},
"dependencies": {
"@emotion/react": "^11.13.3",
@@ -25,7 +25,6 @@
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -34,7 +33,6 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
@@ -43,7 +41,6 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
@@ -52,7 +49,7 @@
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.3.6"
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -66,6 +63,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.4.1"
"vite": "6.3.6"
}
}
+23 -36
View File
@@ -4,52 +4,39 @@ import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { SWRConfig } from "swr";
import { Routes } from "./Routes";
import AppLayout from "@/Components/layout/AppLayout";
import type { RootState } from "@/Types/state";
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
const swrConfig = {
dedupingInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
shouldRetryOnError: false,
errorRetryCount: 2,
errorRetryInterval: 1000,
focusThrottleInterval: 5000,
};
function App() {
const mode = useSelector((state: RootState) => state.ui.mode);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<SWRConfig value={swrConfig}>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.default,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
} as CSSProperties
}
/>
</ThemeProvider>
</SWRConfig>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.default,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
} as CSSProperties
}
/>
</ThemeProvider>
);
}
@@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25);
const latestChecks = checks.slice(-25).reverse();
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
@@ -57,8 +57,7 @@ export const HeatmapResponseTimeTooltip = ({
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
{t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -2,7 +2,6 @@ import { BaseBox } from ".";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
@@ -27,7 +26,7 @@ export const BaseChart = ({
return (
<BaseBox
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
padding: padding ?? theme.spacing(8),
display: "flex",
flex: 1,
width: width,
@@ -35,13 +34,13 @@ export const BaseChart = ({
}}
>
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(8)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(4)}
>
{icon && (
<BaseBox
@@ -3,7 +3,6 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
@@ -92,7 +91,7 @@ export const BasePage = ({
return (
<Stack
spacing={theme.spacing(LAYOUT.LG)}
spacing={theme.spacing(10)}
{...props}
>
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
@@ -221,7 +220,7 @@ export const BaseAuthPage = ({
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(8)}
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
@@ -230,7 +229,7 @@ export const BaseAuthPage = ({
>
<HeaderAuthControls
hideLogo
py={theme.spacing(LAYOUT.XS)}
py={theme.spacing(4)}
position={"absolute"}
top={0}
left={0}
@@ -244,14 +243,14 @@ export const BaseAuthPage = ({
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(SPACING.LG)}
mb={theme.spacing(2)}
>
{title}
</Typography>
<Typography variant="h2">{subtitle}</Typography>
</Stack>
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(8)}
width={{
xs: "80%",
md: "25%",
@@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Check } from "lucide-react";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
export const BulletPointCheck = ({
text,
@@ -22,7 +21,7 @@ export const BulletPointCheck = ({
return (
<Stack
direction="row"
gap={theme.spacing(LAYOUT.SM)}
gap={theme.spacing(6)}
alignItems="center"
>
<Check
@@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -28,7 +27,7 @@ export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
lg: "50%",
xl: "40%",
}}
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
@@ -56,7 +55,7 @@ export const ErrorFallback = ({
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(LAYOUT.XS)}
marginY={theme.spacing(4)}
color={theme.palette.text.secondary}
>
{title}
@@ -82,7 +81,7 @@ export const EmptyFallback = ({
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
@@ -90,7 +89,7 @@ export const EmptyFallback = ({
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
gap: theme.spacing(2),
maxWidth: "1100px",
width: "100%",
}}
@@ -135,7 +134,7 @@ export const EmptyMonitorFallback = ({
return (
<BaseFallback>
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
@@ -148,7 +147,7 @@ export const EmptyMonitorFallback = ({
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
gap: theme.spacing(2),
maxWidth: "1100px",
width: "100%",
}}
@@ -1,13 +1,12 @@
import type { Monitor } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { PulseDot, Dot } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -29,7 +28,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(4)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
@@ -42,12 +41,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
>
{formatUrl(monitor?.url)}
</Typography>
{monitor.type === "pagespeed" && monitor.strategy && (
<>
<Dot />
<StrategyBadge strategy={monitor.strategy} />
</>
)}
{!isSmall && (
<>
<Dot />
@@ -3,7 +3,6 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const SplitBox = ({
left,
right,
@@ -19,10 +18,10 @@ export const SplitBox = ({
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius}
borderRadius={theme.spacing(2)}
>
<Box
padding={theme.spacing(LAYOUT.XXL)}
padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
@@ -38,7 +37,7 @@ export const SplitBox = ({
</Box>
<Box
flex={1}
padding={theme.spacing(LAYOUT.XXL)}
padding={theme.spacing(15)}
>
{right}
</Box>
@@ -61,7 +60,7 @@ export const ConfigBox = ({
return (
<SplitBox
left={
<Stack spacing={theme.spacing(LAYOUT.XS)}>
<Stack spacing={theme.spacing(4)}>
<Typography
textTransform={"capitalize"}
component="h2"
@@ -1,32 +0,0 @@
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { PageSpeedStrategy } from "@/Types/Monitor";
import { SPACING } from "@/Utils/Theme/constants";
interface StrategyBadgeProps {
strategy: PageSpeedStrategy | undefined;
variant?: "caption" | "body2";
}
export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!strategy) {
return null;
}
return (
<Typography
variant={variant}
px={SPACING.XXL}
py={SPACING.SM}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.secondary.light}
color={theme.palette.text.primary}
>
{t(`pages.pageSpeed.strategy.${strategy}`)}
</Typography>
);
};
+10 -11
View File
@@ -29,7 +29,6 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next";
import { useState, Fragment, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import type { SxProps, Theme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -91,20 +90,20 @@ export function DataTable<
// Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) {
return (
<Stack spacing={theme.spacing(LAYOUT.XS)}>
<Stack spacing={theme.spacing(4)}>
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(LAYOUT.XS)}
spacing={theme.spacing(4)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
padding: theme.spacing(4),
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
@@ -156,7 +155,7 @@ export function DataTable<
<Table
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(LAYOUT.MD),
paddingLeft: theme.spacing(8),
},
"& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`,
@@ -169,13 +168,13 @@ export function DataTable<
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
overflowX: "hidden",
},
@@ -515,7 +514,7 @@ export const Pagination = ({ ...props }: PaginationProps) => {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
rowGap: theme.spacing(SPACING.SM),
rowGap: theme.spacing(1),
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
@@ -557,8 +556,8 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
alignItems={"center"}
justifyContent={"center"}
sx={{
py: theme.spacing(LAYOUT.XL),
px: theme.spacing(LAYOUT.XS),
py: theme.spacing(12),
px: theme.spacing(4),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
@@ -575,7 +574,7 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(SPACING.LG),
mb: theme.spacing(2),
}}
>
<Icon
@@ -21,4 +21,3 @@ export * from "./SplitBox";
export * from "./TextLink";
export * from "./OfflineBanner";
export * from "./Avatar";
export * from "./StrategyBadge";
+3 -19
View File
@@ -11,26 +11,20 @@ import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"];
showValue?: boolean;
formatDisplayValue?: (value: number) => string;
}
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, formatDisplayValue, ...props }, ref) => {
({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
const displayValue =
showValue && formatDisplayValue && typeof props.value === "number"
? formatDisplayValue(props.value)
: props.value;
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{displayValue}</Typography>}
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
@@ -69,21 +63,12 @@ interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
formatDisplayValue?: (value: number) => string;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{
fieldLabel,
required,
showValue = true,
formatDisplayValue,
value,
sliderMaxWidth = "100%",
...props
},
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
@@ -97,7 +82,6 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
<SliderInput
{...props}
showValue={showValue}
formatDisplayValue={formatDisplayValue}
value={value}
ref={ref}
/>
@@ -1,6 +1,9 @@
import Box from "@mui/material/Box";
import { useState, useEffect, useRef } from "react";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import BackgroundSVG from "@/assets/Images/background.svg";
import type { RootState } from "@/Types/state";
import { OfflineBanner } from "@/Components/design-elements";
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
@@ -10,6 +13,7 @@ interface AppLayoutProps {
const AppLayout = ({ children }: AppLayoutProps) => {
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const [serverUnreachable, setServerUnreachable] = useState(false);
const retryIntervalRef = useRef<number | null>(null);
@@ -43,6 +47,10 @@ const AppLayout = ({ children }: AppLayoutProps) => {
sx={{
minHeight: "100vh",
backgroundColor: theme.palette.background.default,
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<OfflineBanner visible={serverUnreachable} />
@@ -5,18 +5,8 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
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",
};
const types = ["http", "ping", "port", "docker", "game"];
const statuses = ["up", "down"];
const states = ["active", "paused"];
@@ -40,7 +30,6 @@ export const ControlsFilter = ({
onClearFilters: () => void;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
@@ -61,7 +50,7 @@ export const ControlsFilter = ({
key={type}
value={type}
>
<Typography>{typeDisplayNames[type] ?? type}</Typography>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
@@ -99,7 +88,7 @@ export const ControlsFilter = ({
variant="contained"
onClick={onClearFilters}
>
{t("common.buttons.clearFilters")}
Clear Filters
</Button>
)}
</Stack>
@@ -1,203 +0,0 @@
import { useRef, useEffect, useState } from "react";
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
}
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const mapRef = useRef<MapRef>(null);
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const mapPopupStyles = (
<GlobalStyles
styles={{
".maplibregl-popup-content": {
background: "transparent !important",
padding: "0 !important",
boxShadow: "none !important",
},
".maplibregl-popup-tip": {
display: "none",
},
".maplibregl-ctrl-attrib": {
display: "none",
},
".maplibregl-ctrl-logo": {
display: "none",
},
".maplibregl-popup-close-button": {
color: theme.palette.text.primary,
background: "transparent",
fontSize: "20px",
padding: "4px",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
}}
/>
);
useEffect(() => {
if (geoChecks.length === 0 || !mapRef.current) return;
const bounds = geoChecks.reduce(
(acc, check) => {
return {
minLng: Math.min(acc.minLng, check.location.longitude),
maxLng: Math.max(acc.maxLng, check.location.longitude),
minLat: Math.min(acc.minLat, check.location.latitude),
maxLat: Math.max(acc.maxLat, check.location.latitude),
};
},
{
minLng: Infinity,
maxLng: -Infinity,
minLat: Infinity,
maxLat: -Infinity,
}
);
if (bounds.minLng !== Infinity) {
mapRef.current.fitBounds(
[
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
],
{ padding: 50, duration: 1000 }
);
}
}, [geoChecks]);
const getMarkerColor = (status: boolean): string => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
<Box
height={500}
width={"100%"}
borderRadius={theme.shape.borderRadius}
overflow={"hidden"}
>
<Map
ref={mapRef}
initialViewState={{
longitude: 0,
latitude: 20,
zoom: 1.5,
}}
style={{ height: "100%", width: "100%" }}
mapStyle={
isDarkMode
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
}
>
{geoChecks.map((check, index) => (
<Marker
key={`${check.id}-${index}`}
longitude={check.location.longitude}
latitude={check.location.latitude}
anchor="bottom"
onClick={(e: any) => {
e.originalEvent.stopPropagation();
setSelectedCheck(check);
}}
>
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getMarkerColor(check.status),
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
/>
</Marker>
))}
{selectedCheck && (
<Popup
longitude={selectedCheck.location.longitude}
latitude={selectedCheck.location.latitude}
anchor="top"
onClose={() => setSelectedCheck(null)}
closeOnClick={false}
>
<Box
sx={{
minWidth: 200,
p: theme.spacing(4),
bgcolor: theme.palette.background.paper,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 1,
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
>
{selectedCheck.location.city}, {selectedCheck.location.country}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status:
</Typography>{" "}
{selectedCheck.status ? "Up" : "Down"}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{new Date(selectedCheck.createdAt).toLocaleString()}
</Typography>
</Stack>
</Box>
</Popup>
)}
</Map>
</Box>
</>
);
};
@@ -1,54 +0,0 @@
import Stack from "@mui/material/Stack";
import { Tabs, Tab } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { GeoContinent } from "@/Types/GeoCheck";
interface HeaderGeoTabsProps {
geoCheckEnabled: boolean;
locations: GeoContinent[] | undefined;
selectedLocation: GeoContinent;
onLocationChange: (location: GeoContinent) => void;
}
export const HeaderGeoTabs = ({
geoCheckEnabled,
locations,
selectedLocation,
onLocationChange,
}: HeaderGeoTabsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!geoCheckEnabled || !locations || locations.length === 0) {
return null;
}
const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
onLocationChange(newValue);
};
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"flex-start"}
>
<Tabs
value={selectedLocation}
onChange={handleChange}
>
{locations.map((location) => (
<Tab
key={location}
label={t(
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
)}
value={location}
/>
))}
</Tabs>
</Stack>
);
};
@@ -2,7 +2,6 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/monitors";
import type { TooltipProps } from "recharts";
@@ -31,8 +30,8 @@ export const HistogramPageSpeedScoresTooltip = ({
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(SPACING.LG),
px: theme.spacing(LAYOUT.XS),
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
@@ -55,12 +54,12 @@ export const HistogramPageSpeedScoresTooltip = ({
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.XXL)}
mt={theme.spacing(SPACING.SM)}
gap={theme.spacing(3)}
mt={theme.spacing(1)}
>
<Box
width={theme.spacing(LAYOUT.XS)}
height={theme.spacing(LAYOUT.XS)}
width={theme.spacing(4)}
height={theme.spacing(4)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/>
<Typography
-2
View File
@@ -1,8 +1,6 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls";
export * from "./HeaderGeoTabs";
export * from "./GeoChecksMap";
export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
+8 -14
View File
@@ -5,7 +5,6 @@ import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
@@ -65,9 +64,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
});
const menuItemSx = {
gap: theme.spacing(LAYOUT.MD),
gap: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(LAYOUT.XS),
pl: theme.spacing(4),
"& svg": { stroke: theme.palette.text.secondary },
};
@@ -75,9 +74,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack
direction="row"
alignItems="center"
py={theme.spacing(LAYOUT.XS)}
px={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(LAYOUT.MD)}
py={theme.spacing(4)}
px={theme.spacing(8)}
gap={theme.spacing(4)}
>
<Avatar
small
@@ -89,7 +88,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.MD)}
gap={theme.spacing(2)}
minWidth={0}
flex={1}
>
@@ -133,15 +132,10 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
disableScrollLock
anchorOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
sx: {
mt: theme.spacing(-LAYOUT.XS),
ml: collapsed ? theme.spacing(SPACING.LG) : 0,
},
},
paper: { sx: { mt: theme.spacing(-4), ml: collapsed ? theme.spacing(2) : 0 } },
}}
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
sx={{ ml: theme.spacing(LAYOUT.XS) }}
sx={{ ml: theme.spacing(4) }}
>
{collapsed && (
<Box
+12 -10
View File
@@ -1,7 +1,6 @@
import { Typography, IconButton, Stack, Box } from "@mui/material";
import { Icon } from "@/Components/design-elements";
import { X } from "lucide-react";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
@@ -35,22 +34,25 @@ export const StarPrompt = ({
return (
<Stack
direction="column"
gap={theme.spacing(LAYOUT.MD)}
padding={theme.spacing(LAYOUT.SM)}
width={"100%"}
borderTop={`1px solid ${theme.palette.divider}`}
borderBottom={`1px solid ${theme.palette.divider}`}
sx={{
width: "100%",
padding: `${theme.spacing(6)} ${theme.spacing(6)}`,
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
gap: theme.spacing(1.5),
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
width="100%"
pl={theme.spacing(LAYOUT.XS)}
pl={theme.spacing(4)}
>
<Typography
variant="subtitle2"
mt={theme.spacing(SPACING.XXL)}
mt={theme.spacing(3)}
>
{t("components.sidebar.starPrompt.title")}
</Typography>
@@ -76,7 +78,7 @@ export const StarPrompt = ({
<Typography
sx={{
mb: 1,
px: theme.spacing(LAYOUT.XS),
px: theme.spacing(4),
}}
>
{t("components.sidebar.starPrompt.description")}
@@ -94,7 +96,7 @@ export const StarPrompt = ({
"&:hover": {
opacity: 0.8,
},
pl: theme.spacing(LAYOUT.XS),
pl: theme.spacing(4),
filter: mode === "dark" ? "invert(1)" : "none",
}}
/>
+7 -8
View File
@@ -3,7 +3,6 @@ import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -66,9 +65,9 @@ export const Sidebar = () => {
left={0}
minHeight={"100vh"}
maxHeight={"100vh"}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.SM)}
gap={theme.spacing(LAYOUT.SM)}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
sx={{
@@ -82,13 +81,13 @@ export const Sidebar = () => {
component="nav"
disablePadding
sx={{
px: theme.spacing(LAYOUT.SM),
px: theme.spacing(6),
flex: 1,
}}
>
<Logo
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
pt={theme.spacing(8)}
pb={theme.spacing(10)}
/>
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
@@ -106,7 +105,7 @@ export const Sidebar = () => {
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(LAYOUT.SM) }}
sx={{ px: theme.spacing(6) }}
>
{bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
+6 -7
View File
@@ -4,7 +4,6 @@ import type { AxiosRequestConfig } from "axios";
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
interface ApiResponse<T> {
success: boolean;
@@ -18,7 +17,7 @@ const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
};
export const useGet = <T>(
url: string | null | undefined,
url: string | null,
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
@@ -62,7 +61,7 @@ export const usePost = <B = any, R = any>() => {
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
logger.error("POST request failed", err, { endpoint, body });
console.error(err);
toastError(errMsg);
setError(errMsg);
return null;
@@ -97,7 +96,7 @@ export const usePatch = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
logger.error("PATCH request failed", err, { endpoint, body });
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -133,7 +132,7 @@ export const usePut = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
logger.error("PUT request failed", err, { endpoint, body });
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -168,7 +167,7 @@ export const useDelete = <R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
logger.error("DELETE request failed", err, { endpoint });
console.error(err);
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -202,7 +201,7 @@ export const useLazyGet = <R = any>() => {
});
return res.data;
} catch (err: any) {
logger.error("GET request failed", err, { endpoint });
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
+1 -21
View File
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -15,7 +14,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckEnabled: data?.geoCheckEnabled || false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
});
@@ -74,22 +73,11 @@ export const useMonitorForm = ({
gameId: data?.gameId || "",
};
break;
case "grpc":
defaults = {
...base,
type: "grpc",
url: data?.url || "",
port: data?.port || 50051,
grpcServiceName: data?.grpcServiceName || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed":
defaults = {
...base,
type: "pagespeed",
url: data?.url || "",
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
};
break;
case "hardware":
@@ -105,14 +93,6 @@ export const useMonitorForm = ({
selectedDisks: data?.selectedDisks || [],
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
default:
defaults = {
...base,
+15 -63
View File
@@ -1,76 +1,28 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults = buildDefaults(data);
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
return { schema: notificationSchema, defaults };
}, [data]);
};
+6 -18
View File
@@ -1,14 +1,14 @@
import { useMemo } from "react";
import { settingsSchema } from "@/Validation/settings";
import type { Settings } from "@/Types/Settings";
import type { SettingsFormInput } from "@/Validation/settings";
import type { SettingsFormData } from "@/Validation/settings";
interface UseSettingsFormOptions {
data?: Settings | null;
}
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
return useMemo(() => {
const defaults: SettingsFormInput = {
const defaults: SettingsFormData = {
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
@@ -22,22 +22,10 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
globalThresholds: {
cpu:
data?.globalThresholds?.cpu && data.globalThresholds.cpu >= 1
? data.globalThresholds.cpu
: 80,
memory:
data?.globalThresholds?.memory && data.globalThresholds.memory >= 1
? data.globalThresholds.memory
: 80,
disk:
data?.globalThresholds?.disk && data.globalThresholds.disk >= 1
? data.globalThresholds.disk
: 80,
temperature:
data?.globalThresholds?.temperature && data.globalThresholds.temperature >= 1
? data.globalThresholds.temperature
: 80,
cpu: data?.globalThresholds?.cpu ?? 0,
memory: data?.globalThresholds?.memory ?? 0,
disk: data?.globalThresholds?.disk ?? 0,
temperature: data?.globalThresholds?.temperature ?? 0,
},
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
-2
View File
@@ -27,14 +27,12 @@ export const useStatusPageForm = ({
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#4169E1",
monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true,
showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo),
};
+11 -64
View File
@@ -11,17 +11,12 @@ import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User";
import { useTranslation } from "react-i18next";
import type { UserRole, User } from "@/Types/User";
import { useParams, useNavigate } from "react-router-dom";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useEditUserForm } from "@/Hooks/useEditUserForm";
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi";
import { useGet, usePatch } from "@/Hooks/UseApi";
import type { EditUserFormData } from "@/Validation/editUser";
import { useEffect, useState } from "react";
import { DialogInput } from "@/Components/inputs/Dialog";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { useEffect } from "react";
interface RoleOption {
id: UserRole;
@@ -34,12 +29,6 @@ const EditUserPage = () => {
const { userId } = useParams<{ userId: string }>();
const { resolver, defaults } = useEditUserForm();
const { patch, loading: isSaving } = usePatch();
const { deleteFn, loading: isDeleting } = useDelete();
const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const currentUser = useSelector((state: RootState) => state.auth.user);
const isSuperAdmin = useIsSuperAdmin();
const isAdmin = useIsAdmin();
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
@@ -71,20 +60,6 @@ const EditUserPage = () => {
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
!user?.role?.includes("demo") &&
(isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin"));
const handleDeleteUser = async () => {
const result = await deleteFn(`/auth/users/${userId}`);
setShowDeleteDialog(false);
if (result) {
navigate("/account", { state: { tab: "team" } });
}
};
const onSubmit = async (data: EditUserFormData) => {
await patch(`/auth/users/${userId}`, data);
};
@@ -190,45 +165,17 @@ const EditUserPage = () => {
</Stack>
}
/>
<Stack
gap={LAYOUT.XS}
direction="row"
justifyContent={"flex-end"}
width="100%"
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{canDeleteUser && (
<Button
variant="contained"
color="error"
onClick={() => setShowDeleteDialog(true)}
sx={{ minWidth: 100 }}
>
{t("common.buttons.removeUser")}
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
{t("common.buttons.save")}
</Button>
</Stack>
</form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
);
};
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Table } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import type { User } from "@/Types/User";
interface TeamTableProps {
@@ -13,7 +13,7 @@ interface TeamTableProps {
export const TeamTable = ({ users }: TeamTableProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const isSuperAdmin = useIsSuperAdmin();
const headers: Header<User>[] = [
{
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
];
const handleRowClick = (row: User) => {
if (isAdmin) {
if (isSuperAdmin) {
navigate(`/account/team/${row.id}`);
}
};
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
<Table
headers={headers}
data={users}
onRowClick={isAdmin ? handleRowClick : undefined}
onRowClick={isSuperAdmin ? handleRowClick : undefined}
/>
);
};
@@ -5,6 +5,7 @@ import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useNavigate } from "react-router";
import type { Check } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
@@ -28,6 +29,7 @@ export const ChecksTable = ({
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const navigate = useNavigate();
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<Check>[] = [
@@ -107,6 +109,9 @@ export const ChecksTable = ({
<Table
headers={headers}
data={checks}
onRowClick={(row) => {
navigate(`/checks/${row.id}`);
}}
emptyViewText={t("pages.checks.table.empty")}
/>
<Pagination
+43 -215
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -16,7 +15,6 @@ import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import { Trash2 } from "lucide-react";
import { HeaderDeleteControls } from "@/Components/monitors";
import { GeoContinents } from "@/Types/GeoCheck";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import {
@@ -29,16 +27,10 @@ import {
SliderWithLabel,
Dialog,
} from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm";
import {
type Monitor,
type MonitorType,
type GamesMap,
supportsGeoCheck,
DefaultPageSpeedStrategy,
} from "@/Types/Monitor";
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
import { GeoCheckLocations } from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor";
@@ -50,9 +42,6 @@ interface GeneralSettingsConfig {
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
showStrategy: boolean;
}
const getGeneralSettingsConfig = (
@@ -68,9 +57,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -80,9 +66,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -92,9 +75,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -104,9 +84,6 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -116,21 +93,6 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: true,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: true,
showIgnoreTls: true,
showStrategy: false,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -140,9 +102,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: true,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -152,21 +111,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: true,
showGrpcServiceName: false,
showIgnoreTls: false,
showStrategy: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: true,
showStrategy: false,
},
};
return configs[type] || configs.http;
@@ -282,7 +226,7 @@ const CreateMonitorPage = () => {
};
const onError = (errors: unknown) => {
logger.debug("Monitor creation validation errors", errors);
console.log(errors);
};
return (
@@ -309,57 +253,43 @@ const CreateMonitorPage = () => {
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
label={t("pages.common.monitors.monitorTypes.optionHttp")}
label={t("pages.createMonitor.form.type.optionHttp")}
description={t(
"pages.createMonitor.form.type.optionHttpDescription"
)}
/>
<RadioWithDescription
value="ping"
label={t("pages.common.monitors.monitorTypes.optionPing")}
label={t("pages.createMonitor.form.type.optionPing")}
description={t(
"pages.createMonitor.form.type.optionPingDescription"
)}
/>
<RadioWithDescription
value="docker"
label={t("pages.common.monitors.monitorTypes.optionDocker")}
label={t("pages.createMonitor.form.type.optionDocker")}
description={t(
"pages.createMonitor.form.type.optionDockerDescription"
)}
/>
<RadioWithDescription
value="port"
label={t("pages.common.monitors.monitorTypes.optionPort")}
label={t("pages.createMonitor.form.type.optionPort")}
description={t(
"pages.createMonitor.form.type.optionPortDescription"
)}
/>
<RadioWithDescription
value="game"
label={t("pages.common.monitors.monitorTypes.optionGame")}
label={t("pages.createMonitor.form.type.optionGame")}
description={t(
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
<RadioWithDescription
value="grpc"
label={t("pages.common.monitors.monitorTypes.optionGrpc")}
description={t(
"pages.createMonitor.form.type.optionGrpcDescription"
)}
/>
<RadioWithDescription
value="websocket"
label={t("pages.common.monitors.monitorTypes.optionWebSocket")}
description={t(
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
@@ -372,7 +302,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<Controller
@@ -393,31 +323,6 @@ const CreateMonitorPage = () => {
/>
)}
{/* Strategy field - only for pagespeed type */}
{generalSettingsConfig.showStrategy && (
<Controller
name="strategy"
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? DefaultPageSpeedStrategy}
fieldLabel={t(
"pages.createMonitor.form.general.option.strategy.label"
)}
error={!!fieldState.error}
>
<MenuItem value="desktop">
{t("pages.createMonitor.form.general.option.strategy.desktop")}
</MenuItem>
<MenuItem value="mobile">
{t("pages.createMonitor.form.general.option.strategy.mobile")}
</MenuItem>
</Select>
)}
/>
)}
{/* Port field - only for port and game types */}
{generalSettingsConfig.showPort && (
<Controller
@@ -473,30 +378,6 @@ const CreateMonitorPage = () => {
/>
)}
{/* gRPC Service Name field - only for grpc type */}
{generalSettingsConfig.showGrpcServiceName && (
<Controller
name="grpcServiceName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t(
"pages.createMonitor.form.general.option.grpcServiceName.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.grpcServiceName.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
<Controller
@@ -616,7 +497,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.thresholds.title")}
subtitle={t("pages.createMonitor.form.thresholds.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="cpuAlertThreshold"
control={control}
@@ -698,7 +579,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(12)}>
<Controller
name="statusWindowSize"
control={control}
@@ -750,7 +631,7 @@ const CreateMonitorPage = () => {
(field.value ?? []).includes(n.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={notificationOptions}
@@ -801,9 +682,7 @@ const CreateMonitorPage = () => {
}
/>
{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
@@ -815,7 +694,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -836,7 +715,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="useAdvancedMatching"
control={control}
@@ -844,7 +723,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -859,7 +738,7 @@ const CreateMonitorPage = () => {
)}
/>
{watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="matchMethod"
control={control}
@@ -946,12 +825,13 @@ const CreateMonitorPage = () => {
/>
)}
{supportsGeoCheck(watchedType) && (
{/* Geo-distributed Checks Configuration */}
{watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckEnabled"
control={control}
@@ -959,7 +839,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -972,70 +852,34 @@ const CreateMonitorPage = () => {
)}
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="geoCheckLocations"
control={control}
render={({ field }) => {
// Map continents to have 'name' property for Autocomplete
const locationOptions = GeoContinents.map((continent) => ({
id: continent,
const locationOptions = GeoCheckLocations.map((loc) => ({
id: loc,
name: t(
`pages.createMonitor.form.geoChecks.option.locations.options.${continent}`
`pages.createMonitor.form.geoChecks.option.locations.options.${loc}`
),
}));
const selectedLocations = locationOptions.filter((loc) =>
(field.value ?? []).includes(loc.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof locationOptions) => {
field.onChange(newValue.map((loc) => loc.id));
}}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.locations.label"
)}
/>
{selectedLocations.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedLocations.map((location, index) => (
<Stack
direction="row"
alignItems="center"
key={location.id}
width="100%"
>
<Typography flexGrow={1}>{location.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== location.id
)
);
}}
aria-label="Remove location"
>
<Trash2 size={16} />
</IconButton>
{index < selectedLocations.length - 1 && <Divider />}
</Stack>
))}
</Stack>
<Autocomplete
multiple={true}
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => option.name}
onChange={(_, newValue: typeof locationOptions) => {
field.onChange(newValue.map((loc) => loc.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.locations.label"
)}
</Stack>
/>
);
}}
/>
@@ -1044,32 +888,16 @@ const CreateMonitorPage = () => {
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? 300000}
onChange={(e) => field.onChange(Number(e.target.value))}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.interval.label"
)}
>
<MenuItem value={300000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fiveMinutes"
)}
</MenuItem>
<MenuItem value={600000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.tenMinutes"
)}
</MenuItem>
<MenuItem value={900000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fifteenMinutes"
)}
</MenuItem>
<MenuItem value={1800000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.thirtyMinutes"
)}
</MenuItem>
<MenuItem value={300000}>5 min</MenuItem>
<MenuItem value={600000}>10 min</MenuItem>
<MenuItem value={900000}>15 min</MenuItem>
<MenuItem value={1800000}>30 min</MenuItem>
</Select>
)}
/>
@@ -3,7 +3,6 @@ import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import type { Incident } from "@/Types/Incident";
@@ -30,14 +29,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
}
return (
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(4)}
sx={sx}
>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")}
</Typography>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")}
</Typography>
@@ -45,7 +44,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
spacing={theme.spacing(4)}
alignItems="center"
>
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
@@ -76,15 +75,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Grid>
</Stack>
</BaseBox>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
spacing={theme.spacing(2)}
>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
@@ -128,15 +127,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Stack>
</BaseBox>
{!incident.status && (
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.resolutionDetails")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(LAYOUT.MD)}
spacing={theme.spacing(2)}
alignItems="center"
>
<Grid size={2}>
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(1);
const [activeTab, setActiveTab] = useState<number>(2);
return (
<BasePage>
<Tabs
+10 -11
View File
@@ -1,9 +1,7 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import {
TextField,
@@ -110,7 +108,7 @@ const CreateMaintenanceWindowPage = () => {
const isLoading = isPosting || isPatching;
const onError = (errors: any) => {
logger.error("Maintenance form submission failed", undefined, { errors });
console.error("Form submission errors:", errors);
};
return (
@@ -122,7 +120,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="name"
control={control}
@@ -172,7 +170,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="startDate"
control={control}
@@ -199,7 +197,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="startTime"
control={control}
@@ -220,7 +218,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
spacing={theme.spacing(4)}
>
<Controller
name="duration"
@@ -285,7 +283,7 @@ const CreateMaintenanceWindowPage = () => {
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={monitorsList}
@@ -319,8 +317,9 @@ const CreateMaintenanceWindowPage = () => {
key={monitor.id}
direction="row"
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
@@ -351,7 +350,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Button
loading={isLoading}
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && watchedType !== "telegram" && (
{watchedType !== "matrix" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
defaultValue={defaults.address}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -171,49 +171,6 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -223,7 +180,7 @@ const NotificationsCreatePage = () => {
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
defaultValue={defaults.homeserverUrl}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -239,7 +196,7 @@ const NotificationsCreatePage = () => {
<Controller
name="roomId"
control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
defaultValue={defaults.roomId}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -255,7 +212,7 @@ const NotificationsCreatePage = () => {
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
defaultValue={defaults.accessToken}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -1,13 +1,7 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Table,
Pagination,
StatusLabel,
StrategyBadge,
} from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -185,19 +179,7 @@ export const PageSpeedMonitorsTable = ({
</Stack>
),
render: (row) => {
return (
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XS}
>
<Typography>{row.name}</Typography>
<StrategyBadge
strategy={row.strategy}
variant="caption"
/>
</Stack>
);
return row.name;
},
},
{
+9 -23
View File
@@ -8,7 +8,7 @@ import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/Pa
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
@@ -20,6 +20,14 @@ const PageSpeedMonitorsPage = () => {
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10";
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(monitorsUrl, {}, { refreshInterval: 30000 });
const settingsUrl = "/settings";
const {
data: settingsData,
@@ -34,28 +42,6 @@ const PageSpeedMonitorsPage = () => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (sortField) params.append("field", sortField);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortField, sortOrder]);
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;
+103 -129
View File
@@ -1,15 +1,6 @@
import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
import {
Stack,
useTheme,
MenuItem,
Link,
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { Stack, useTheme, MenuItem, Link, type SelectChangeEvent } from "@mui/material";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -20,10 +11,10 @@ import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi";
import { useToast } from "@/Hooks/UseToast";
import { useSettingsForm } from "@/Hooks/useSettingsForm";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings";
import type { SettingsFormData } from "@/Validation/settings";
import { useState } from "react";
import { Controller } from "react-hook-form";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
import { TextField, Button, FieldLabel } from "@/Components/inputs";
import { Box, Typography } from "@mui/material";
import { useDelete } from "@/Hooks/UseApi";
@@ -37,7 +28,6 @@ import {
} from "@/Features/UI/uiSlice.js";
import timezones from "@/Utils/timezones.json";
import type { RootState } from "@/Types/state";
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
interface Timezone {
id: string;
@@ -89,7 +79,7 @@ export const SettingsPage = () => {
// Initialize form with schema and defaults
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
const form = useForm<SettingsFormInput, unknown, SettingsFormData>({
const form = useForm<SettingsFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
mode: "onChange",
@@ -256,7 +246,29 @@ export const SettingsPage = () => {
delete (dataToSend as any).systemEmailPassword;
}
const result = await patch("/settings", dataToSend as SettingsFormData);
// Convert undefined to empty string for backend unsetting
const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => {
const typedKey = key as keyof SettingsFormData;
if (value === undefined) {
(acc as any)[typedKey] = "";
} else if (typeof value === "object" && value !== null) {
// Handle nested objects like globalThresholds
// Convert 0 to "" for unsetting thresholds
(acc as any)[typedKey] = Object.entries(value).reduce(
(nested, [nestedKey, nestedValue]) => ({
...nested,
[nestedKey]:
nestedValue === undefined || nestedValue === 0 ? "" : nestedValue,
}),
{}
);
} else {
(acc as any)[typedKey] = value;
}
return acc;
}, {} as Partial<SettingsFormData>);
const result = await patch("/settings", processedData as SettingsFormData);
if (result?.success) {
// Update API key state from response
@@ -270,7 +282,7 @@ export const SettingsPage = () => {
};
const onError = (errors: unknown) => {
logger.debug("Form validation errors", errors);
console.log("Form validation errors:", errors);
};
const languages = Object.keys(i18n.options.resources || {});
@@ -280,7 +292,7 @@ export const SettingsPage = () => {
component="form"
onSubmit={form.handleSubmit(onSubmit, onError)}
>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(8)}>
<ConfigBox
title={t("pages.settings.form.timezone.title")}
subtitle={t("pages.settings.form.timezone.description")}
@@ -303,7 +315,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.ui.title")}
subtitle={t("pages.settings.form.ui.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(8)}>
<Select
value={mode}
onChange={handleModeChange}
@@ -442,43 +454,6 @@ export const SettingsPage = () => {
/>
)}
{/* Check Retention */}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.retention.title")}
subtitle={t("pages.settings.form.retention.description")}
rightContent={
<Controller
name="checkTTL"
control={form.control}
defaultValue={defaults.checkTTL}
render={({ field }) => (
<SliderWithLabel
{...field}
fieldLabel={t("pages.settings.form.retention.option.days.label")}
min={1}
max={CHECK_TTL_SENTINEL}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 30}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
valueLabelFormat={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
formatDisplayValue={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
/>
)}
/>
}
/>
)}
{/* Global Thresholds */}
{isAdmin && (
<ConfigBox
@@ -490,16 +465,24 @@ export const SettingsPage = () => {
name="globalThresholds.cpu"
control={form.control}
defaultValue={defaults.globalThresholds?.cpu}
render={({ field }) => (
<SliderWithLabel
render={({ field, fieldState }) => (
<TextField
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.cpu.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
@@ -507,16 +490,24 @@ export const SettingsPage = () => {
name="globalThresholds.memory"
control={form.control}
defaultValue={defaults.globalThresholds?.memory}
render={({ field }) => (
<SliderWithLabel
render={({ field, fieldState }) => (
<TextField
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.memory.label")}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.memory.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
@@ -524,16 +515,24 @@ export const SettingsPage = () => {
name="globalThresholds.disk"
control={form.control}
defaultValue={defaults.globalThresholds?.disk}
render={({ field }) => (
<SliderWithLabel
render={({ field, fieldState }) => (
<TextField
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.disk.label")}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.disk.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
@@ -541,18 +540,26 @@ export const SettingsPage = () => {
name="globalThresholds.temperature"
control={form.control}
defaultValue={defaults.globalThresholds?.temperature}
render={({ field }) => (
<SliderWithLabel
render={({ field, fieldState }) => (
<TextField
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t(
"pages.settings.form.thresholds.option.temperature.label"
)}
min={1}
max={150}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.temperature.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
@@ -568,7 +575,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.email.title")}
subtitle={t("pages.settings.form.email.description")}
leftContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(8)}>
<TextLink
text={t("pages.settings.form.email.descriptionTransport")}
linkText={t("pages.settings.form.email.descriptionTransportLink")}
@@ -616,7 +623,7 @@ export const SettingsPage = () => {
</Stack>
}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(10)}>
{/* Email Host */}
<Controller
name="systemEmailHost"
@@ -703,7 +710,7 @@ export const SettingsPage = () => {
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(4)}
>
<Button
variant="contained"
@@ -778,7 +785,7 @@ export const SettingsPage = () => {
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(LAYOUT.XS),
gap: theme.spacing(4),
}}
>
{[
@@ -897,7 +904,7 @@ export const SettingsPage = () => {
subtitle={t("pages.settings.form.importExportMonitors.description")}
rightContent={
<Stack
gap={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(4)}
direction={"row"}
>
<input
@@ -983,46 +990,13 @@ export const SettingsPage = () => {
bottom: 0,
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(LAYOUT.MD),
marginLeft: theme.spacing(-LAYOUT.MD),
marginRight: theme.spacing(-LAYOUT.MD),
marginBottom: theme.spacing(-LAYOUT.MD),
padding: theme.spacing(8),
marginLeft: theme.spacing(-8),
marginRight: theme.spacing(-8),
marginBottom: theme.spacing(-8),
zIndex: 1000,
}}
>
{/* Validation Error Display */}
{Object.keys(form.formState.errors).length > 0 && (
<Alert
severity="error"
sx={{ mb: 2, flexGrow: 1, mr: 2 }}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, mb: 1 }}
>
{t("pages.settings.form.validation.errorMessage")}
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: 2 }}
>
{Object.entries(form.formState.errors).map(([field, error]) => {
const message =
typeof error === "object" && error?.message
? error.message
: "Invalid value";
return (
<li key={field}>
<Typography variant="body2">
<strong>{field}:</strong> {message}
</Typography>
</li>
);
})}
</Box>
</Alert>
)}
<Button
loading={isSaving}
type="submit"
+18 -70
View File
@@ -1,7 +1,5 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -21,28 +19,19 @@ import {
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { StatusPageResponse } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
return `/monitors/team?${params.toString()}`;
})();
interface TimezoneOption {
_id: string;
name: string;
@@ -58,11 +47,11 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
useGet<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const { data: monitorsResponse } = useGet<Monitor[]>(
"/monitors/team?type=http&type=ping&type=port&type=docker"
);
const monitors = monitorsResponse ?? [];
const { post, loading: isSubmittingPost } = usePost();
@@ -90,26 +79,8 @@ const CreateStatusPage = () => {
reset(defaults);
}, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
}, [watchedMonitorIds, monitors]);
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
console.log(errors);
};
const handleDeleteClick = () => {
@@ -130,6 +101,7 @@ const CreateStatusPage = () => {
const onSubmit = async (data: StatusPageFormData) => {
const fd = new FormData();
fd.append("type", "uptime");
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
@@ -138,16 +110,11 @@ const CreateStatusPage = () => {
fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
});
data.type.forEach((type) => {
fd.append("type[]", type);
});
// Handle logo upload
if (data.logo === null) {
// Signal to remove the logo
@@ -161,7 +128,7 @@ const CreateStatusPage = () => {
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
console.error("Error fetching logo blob:", e);
}
}
}
@@ -198,7 +165,7 @@ const CreateStatusPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
spacing={theme.spacing(2)}
>
<Controller
name="isPublished"
@@ -220,7 +187,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(6)}>
<Controller
name="companyName"
control={control}
@@ -275,7 +242,7 @@ const CreateStatusPage = () => {
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={monitors}
@@ -323,9 +290,9 @@ const CreateStatusPage = () => {
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
@@ -334,9 +301,7 @@ const CreateStatusPage = () => {
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
@@ -401,7 +366,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
@@ -442,7 +407,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(2)}>
<Controller
name="showCharts"
control={control}
@@ -458,23 +423,6 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
@@ -1,246 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import type { Monitor } from "@/Types/Monitor";
import Grid from "@mui/material/Grid";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
const PERCENTAGE_MULTIPLIER = 100;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricDetail {
label: string;
value: string;
}
interface MetricConfig {
key: string;
label: string;
hasData: boolean;
progress: number;
details: MetricDetail[];
}
const MetricDetailRow = ({ label, value }: MetricDetail) => {
const theme = useTheme();
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Stack>
);
};
interface MetricItemProps {
label: string;
progress: number;
details?: MetricDetail[];
}
const MetricItem = ({ label, progress, details }: MetricItemProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Grid
size={isSmall ? 12 : 4}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: theme.spacing(SPACING.LG),
padding: theme.spacing(LAYOUT.XS),
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
/>
<Typography variant="body2">{label}</Typography>
</Box>
{details && details.length > 0 && (
<Box
width="100%"
paddingX={theme.spacing(LAYOUT.LG)}
>
{details.map((detail) => (
<MetricDetailRow
key={detail.label}
label={detail.label}
value={detail.value}
/>
))}
</Box>
)}
</Grid>
);
};
type LatestCheck = NonNullable<Monitor["recentChecks"]>[number];
const buildCpuMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
return null;
}
const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
return {
key: "cpu",
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
hasData: true,
progress: usagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.usage"),
value: `${usagePercent.toFixed(2)}%`,
},
],
};
};
const buildMemoryMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (
!check.memory ||
typeof check.memory.usage_percent !== "number" ||
typeof check.memory.used_bytes !== "number" ||
typeof check.memory.total_bytes !== "number"
) {
return null;
}
return {
key: "memory",
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
hasData: true,
progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(check.memory.used_bytes ?? 0),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(check.memory.total_bytes ?? 0),
},
],
};
};
const buildDiskMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.disk || check.disk.length === 0) {
return null;
}
const disks = check.disk;
const avgUsagePercent =
(disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
PERCENTAGE_MULTIPLIER;
const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
return {
key: "disk",
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
hasData: true,
progress: avgUsagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(totalUsedBytes),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(totalTotalBytes),
},
],
};
};
export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics: MetricConfig[] = [
buildCpuMetric(latestCheck, t),
buildMemoryMetric(latestCheck, t),
buildDiskMetric(latestCheck, t),
].filter((m): m is MetricConfig => m !== null);
if (metrics.length === 0) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
>
{metrics.map(({ key, label, progress, details }) => (
<MetricItem
key={key}
label={label}
progress={progress}
details={details}
/>
))}
</Grid>
);
};
@@ -5,16 +5,13 @@ import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme, type Theme } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useState } from "react";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
@@ -25,124 +22,29 @@ interface MonitorsListProps {
monitors: StatusPageMonitor[];
}
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
borderRadius: theme.shape.borderRadius,
};
};
const MonitorHeader = ({
monitor,
showURL,
}: {
monitor: StatusPageMonitor;
showURL: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
mb={theme.spacing(LAYOUT.XS)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.LG)}
mb={theme.spacing(SPACING.SM)}
>
<Typography
variant="h6"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
</Typography>
</Stack>
{showURL && monitor.url && (
<Typography
variant="body2"
color={theme.palette.text.secondary}
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
);
};
const MonitorContent = ({
monitor,
statusPage,
chartType,
}: {
monitor: StatusPageMonitor;
statusPage: StatusPage;
chartType: string;
}) => {
const theme = useTheme();
if (monitor.type === "hardware") {
if (statusPage.showInfrastructure === false) return null;
return <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
return (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
checks={checks}
/>
) : (
<HeatmapResponseTime checks={checks} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme();
const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL);
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{statusPage.showCharts && (
<Stack gap={theme.spacing(8)}>
{statusPage.showCharts !== false && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
direction={"row"}
alignItems={"center"}
>
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent
dualOption
value={chartType}
checked={chartType === "histogram"}
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
onChange={(e) => {
setChartType(e.target.checked ? "histogram" : "heatmap");
}}
/>
<Typography>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
@@ -150,22 +52,59 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
</Stack>
)}
{monitors.map((monitor) => (
<BaseBox
key={monitor.id}
padding={theme.spacing(LAYOUT.MD)}
>
<MonitorHeader
monitor={monitor}
showURL={showURL}
/>
<MonitorContent
monitor={monitor}
statusPage={statusPage}
chartType={chartType}
/>
</BaseBox>
))}
{monitors?.map((monitor) => {
return (
<BaseBox
key={monitor.id}
padding={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(4)}
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
{showURL && (
<Typography
variant="body2"
color="text.secondary"
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
{statusPage.showCharts !== false && (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
)}
</Box>
)}
</BaseBox>
);
})}
</Stack>
);
};
+3 -9
View File
@@ -23,22 +23,16 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
{},
{
refreshInterval: 10000,
}
);
const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
const statusPage = data?.statusPage;
const monitors = data?.monitors ?? [];
if (!statusPage) return null;
if (monitors.length === 0) {
if (monitors?.length === 0) {
return (
<BasePage
loading={isLoading}
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => row.type.join(", "),
render: (row) => row.type,
},
{
id: "status",
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
{
id: "date",
content: t("common.table.headers.dateTime"),
content: t("pages.checks.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
@@ -1,107 +0,0 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<FlatGeoCheck>[] = [
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const status = row.status ? "up" : "down";
return <StatusLabel status={status} />;
},
},
{
id: "date",
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
},
{
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
},
},
{
id: "location",
content: t("pages.checks.table.headers.location"),
render: (row) => {
const location = row.location;
if (!location) return "N/A";
const { continent, country, city } = location;
return `${continent} - ${country}, ${city}`;
},
},
{
id: "responseTime",
content: t("common.table.headers.responseTime"),
render: (row) => {
if (!row.timings?.total) return "N/A";
return prettyMilliseconds(row.timings.total, { compact: true });
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
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);
};
return (
<Box>
<Table
headers={headers}
data={geoChecks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+4 -95
View File
@@ -6,12 +6,9 @@ import {
RadialAvgResponse,
HistogramDetails,
HeaderMonitorControls,
HeaderGeoTabs,
GeoChecksMap,
} from "@/Components/monitors";
import { TrendingUp, AlertTriangle } from "lucide-react";
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable";
import { MonitorStatBoxes } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
@@ -20,17 +17,11 @@ import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi";
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type {
GeoChecksResult,
FlatGeoChecksResponse,
GeoContinent,
} from "@/Types/GeoCheck";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -46,10 +37,7 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
@@ -68,7 +56,7 @@ const UptimeDetailsPage = () => {
} = useGet<MonitorDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false }
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitorData = monitorDetailsData?.monitorData;
@@ -83,11 +71,7 @@ const UptimeDetailsPage = () => {
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
{},
{ revalidateOnFocus: false }
);
const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
const certificateExpiry = useMemo(() => {
if (!certificateData?.certificateDate) {
@@ -118,58 +102,9 @@ const UptimeDetailsPage = () => {
const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>(
checksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
{ keepPreviousData: true }
);
const geoChecksUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("continent", selectedLocation);
return `/monitors/${monitorId}/geo-checks?${params.toString()}`;
}, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]);
const { data: geoGroupedData } = useGet<GeoChecksResult>(
geoChecksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? [];
// Fetch paginated geo checks for the table
const geoChecksTableUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("sortOrder", "desc");
params.append("dateRange", dateRange);
params.append("page", String(geoPage));
params.append("rowsPerPage", String(geoRowsPerPage));
return `/geo-checks/${monitorId}?${params.toString()}`;
}, [
monitorId,
monitor?.type,
monitor?.geoCheckEnabled,
dateRange,
geoPage,
geoRowsPerPage,
]);
const { data: geoChecksTableData } = useGet<FlatGeoChecksResponse>(
geoChecksTableUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor?.geoCheckLocations;
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
@@ -192,7 +127,6 @@ const UptimeDetailsPage = () => {
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
@@ -226,31 +160,6 @@ const UptimeDetailsPage = () => {
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
{monitor?.geoCheckEnabled && (
<>
<Typography variant="h1">Location breakdown</Typography>
<HeaderGeoTabs
geoCheckEnabled={monitor?.geoCheckEnabled ?? false}
locations={geoLocations}
selectedLocation={selectedLocation}
onLocationChange={setSelectedLocation}
/>
<HistogramDetails
checks={geoGroupedChecks}
range={dateRange}
/>
<GeoChecksTable
geoChecks={geoChecksForTable}
count={geoChecksCount}
page={geoPage}
setPage={setGeoPage}
rowsPerPage={geoRowsPerPage}
setRowsPerPage={setGeoRowsPerPage}
/>
<GeoChecksMap geoChecks={geoChecksForTable} />
</>
)}
</BasePage>
);
};
@@ -181,7 +181,6 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
@@ -212,7 +211,6 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
</Stack>
+4 -6
View File
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// Status: "up" -> true, "down" -> false
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
@@ -68,9 +68,7 @@ const UptimeMonitorsPage = () => {
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
+17 -3
View File
@@ -1,5 +1,3 @@
export const CHECK_TTL_SENTINEL = 366;
export interface CheckMetadata {
monitorId: string;
teamId: string;
@@ -127,6 +125,9 @@ export interface Check {
timings?: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
@@ -139,6 +140,7 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -158,6 +160,14 @@ export interface PageSpeedGroupedCheck {
totalChecks: number;
}
export interface GroupedGeoCheck {
bucketDate: string;
continent: string;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface LatestCheck {
status: boolean;
responseTime: number;
@@ -234,6 +244,7 @@ export interface UptimeChecksResult {
groupedDownChecks: GroupedCheck[];
uptimePercentage: number;
avgResponseTime: number;
groupedGeoChecks?: GroupedGeoCheck[];
}
export interface ChecksSummary {
@@ -241,7 +252,10 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
> & {
originalResponseTime: number;
};
-80
View File
@@ -1,80 +0,0 @@
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number];
export interface GeoCheckMetadata {
monitorId: string;
teamId: string;
type: string;
}
export interface GeoCheckTimings {
total: number;
dns: number;
tcp: number;
tls: number;
firstByte: number;
download: number;
}
export interface GeoCheckLocation {
continent: GeoContinent;
region: string;
country: string;
state: string;
city: string;
longitude: number;
latitude: number;
}
export interface GeoCheckResult {
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
}
export interface GeoCheck {
id: string;
metadata: GeoCheckMetadata;
results: GeoCheckResult[];
expiry: string;
__v: number;
createdAt: string;
updatedAt: string;
}
export interface FlatGeoCheck {
id: string;
monitorId: string;
teamId: string;
type: string;
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
createdAt: string;
updatedAt: string;
}
export interface GroupedGeoCheck {
bucketDate: string;
continent: GeoContinent;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface GeoChecksResult {
monitorType: string;
groupedGeoChecks: GroupedGeoCheck[];
}
export interface GeoChecksResponse {
geoChecks: GeoCheck[];
geoChecksCount: number;
}
export interface FlatGeoChecksResponse {
geoChecks: FlatGeoCheck[];
geoChecksCount: number;
}
+3 -19
View File
@@ -1,7 +1,5 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MonitorTypes = [
"http",
@@ -11,21 +9,10 @@ export const MonitorTypes = [
"docker",
"port",
"game",
"grpc",
"websocket",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
if (!type) {
return false;
}
return GeoCheckSupportedTypes.includes(type);
};
export const MonitorStatuses = [
"up",
"down",
@@ -38,9 +25,8 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
export interface Monitor {
id: string;
@@ -75,11 +61,9 @@ export interface Monitor {
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
grpcServiceName?: string;
strategy?: PageSpeedStrategy;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckLocations?: GeoCheckLocation[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
-2
View File
@@ -5,8 +5,6 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
"telegram",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+2 -24
View File
@@ -1,31 +1,10 @@
import type { Monitor, MonitorType } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
ping: "pages.common.monitors.monitorTypes.optionPing",
docker: "pages.common.monitors.monitorTypes.optionDocker",
port: "pages.common.monitors.monitorTypes.optionPort",
game: "pages.common.monitors.monitorTypes.optionGame",
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
hardware: "pages.common.monitors.monitorTypes.optionHardware",
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
};
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const key = MONITOR_TYPE_KEYS[type];
return key ? t(key) : type;
};
import type { Monitor } from "@/Types/Monitor";
export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: MonitorDisplayType[];
type: string;
companyName: string;
url: string;
timezone?: string;
@@ -41,7 +20,6 @@ export interface StatusPage {
showCharts: boolean;
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
-6
View File
@@ -4,7 +4,6 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_APP_API_V2_BASE_URL?: string;
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
}
interface ImportMeta {
@@ -18,8 +17,3 @@ declare module "*.svg?react" {
>;
export default ReactComponent;
}
declare module "*.css" {
const content: string;
export default content;
}
-2
View File
@@ -8,8 +8,6 @@ export const getMonitorPath = (type: MonitorType): string => {
port: "uptime",
ping: "uptime",
game: "uptime",
grpc: "uptime",
websocket: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
-6
View File
@@ -12,8 +12,6 @@ export const typographyLevels = {
export const colors = {
gray200: "#EFEFEF",
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
blueBlueWave: "#1570EF",
};
@@ -33,8 +31,4 @@ export const darkPalette = {
secondary: {
main: colors.gray700,
},
background: {
default: colors.gray900,
paper: colors.gray850,
},
};
-17
View File
@@ -1,17 +0,0 @@
export const SPACING = {
XS: 0.5,
SM: 1,
MD: 1.5,
LG: 2,
XL: 2.5,
XXL: 3,
} as const;
export const LAYOUT = {
XS: 4,
SM: 6,
MD: 8,
LG: 10,
XL: 12,
XXL: 16,
} as const;
-137
View File
@@ -1,137 +0,0 @@
import type { LogLevel } from "@/Types/Log";
interface LogContext {
[key: string]: any;
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
interface ErrorLogData {
timestamp: string;
level: LogLevel;
message: string;
error?: {
message: string;
name: string;
stack?: string;
};
context?: LogContext;
url: string;
userAgent: string;
}
const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => {
return LOG_LEVEL_PRIORITY[level] >= configuredPriority;
};
const formatError = (error?: Error) => {
if (!error) return undefined;
return {
message: error.message,
name: error.name,
stack: error.stack,
};
};
const createLogData = (
level: LogLevel,
message: string,
error?: Error,
context?: LogContext
): ErrorLogData => {
return {
timestamp: new Date().toISOString(),
level,
message,
error: formatError(error),
context,
url: window.location.href,
userAgent: navigator.userAgent,
};
};
const error = (message: string, error?: Error, context?: LogContext): void => {
if (!shouldLog("error")) return;
const logData = createLogData("error", message, error, context);
if (configuredLevel === "debug") {
console.group(`ERROR: ${message}`);
if (error) {
console.error("Error:", error);
}
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.log("URL:", logData.url);
console.log("Timestamp:", logData.timestamp);
console.groupEnd();
} else {
console.error(JSON.stringify(logData));
}
};
const warn = (message: string, context?: LogContext): void => {
if (!shouldLog("warn")) return;
const logData = createLogData("warn", message, undefined, context);
if (configuredLevel === "debug") {
console.group(`WARN: ${message}`);
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.groupEnd();
} else {
console.warn(JSON.stringify(logData));
}
};
const debug = (message: string, data?: any): void => {
if (!shouldLog("debug")) return;
console.group(`DEBUG: ${message}`);
if (data !== undefined) {
console.log(data);
}
console.groupEnd();
};
const info = (message: string, data?: any): void => {
if (!shouldLog("info")) return;
if (configuredLevel === "debug") {
console.log(`INFO: ${message}`, data);
} else {
const logData = {
timestamp: new Date().toISOString(),
level: "info" as LogLevel,
message,
data,
};
console.log(JSON.stringify(logData));
}
};
export interface ILogger {
error(message: string, error?: Error, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
debug(message: string, data?: any): void;
info(message: string, data?: any): void;
}
export const logger: ILogger = {
error,
warn,
debug,
info,
};
+2 -31
View File
@@ -1,6 +1,4 @@
import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
import { PageSpeedStrategies } from "@/Types/Monitor";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -23,11 +21,8 @@ const baseSchema = z.object({
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional(),
geoCheckLocations: z.array(z.enum(["EU", "NA", "AS", "SA", "AF", "OC"])).optional(),
geoCheckInterval: z.number().min(5, "Interval must be at least 5 minutes").optional(),
});
// HTTP monitor schema
@@ -74,23 +69,10 @@ const gameSchema = baseSchema.extend({
gameId: z.string().min(1, "Game type is required"),
});
// gRPC monitor schema
const grpcSchema = baseSchema.extend({
type: z.literal("grpc"),
url: z.string().min(1, "Host is required"),
port: z
.number()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
grpcServiceName: z.string().optional(),
ignoreTlsErrors: z.boolean(),
});
// PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
url: urlSchema,
strategy: z.enum(PageSpeedStrategies),
});
// Hardware/Infrastructure monitor schema
@@ -117,13 +99,6 @@ const hardwareSchema = baseSchema.extend({
selectedDisks: z.array(z.string()),
});
// WebSocket monitor schema
const websocketSchema = baseSchema.extend({
type: z.literal("websocket"),
url: z.string().min(1, "WebSocket URL is required"),
ignoreTlsErrors: z.boolean(),
});
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
@@ -131,10 +106,8 @@ export const monitorSchema = z.discriminatedUnion("type", [
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -146,8 +119,6 @@ export {
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
};
-13
View File
@@ -45,17 +45,6 @@ const matrixSchema = baseSchema.extend({
accessToken: z.string().min(1, "Access token is required"),
});
const teamsSchema = baseSchema.extend({
type: z.literal("teams"),
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -63,8 +52,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
telegramSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+37 -20
View File
@@ -1,4 +1,3 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z.object({
@@ -7,50 +6,68 @@ export const settingsSchema = z.object({
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.string()
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.transform((val) => (val === "" ? undefined : val.toLowerCase().trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
globalThresholds: z
.object({
cpu: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
memory: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
disk: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
temperature: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(150, "Maximum value is 150")
.optional(),
})
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
});
export type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
-4
View File
@@ -14,16 +14,12 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens"
),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
showCharts: z.boolean(),
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
logo: z
.object({
+1 -8
View File
@@ -288,13 +288,7 @@ const logoImageValidation = joi
.optional(); // Make entire object optional
const statusPageValidation = joi.object({
type: joi
.alternatives()
.try(
joi.string().valid("uptime", "infrastructure"),
joi.array().items(joi.string().valid("uptime", "infrastructure"))
)
.required(),
type: joi.string().valid("uptime").required(),
isPublished: joi.bool(),
companyName: joi
.string()
@@ -323,7 +317,6 @@ const statusPageValidation = joi.object({
showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
});
const settingsValidation = joi.object({
+38 -117
View File
@@ -45,9 +45,7 @@
"removeMonitors": "Remove monitors",
"sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON",
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters",
"removeUser": "Remove user"
"importFromJSON": "Import from JSON"
},
"charts": {
"labels": {
@@ -117,8 +115,7 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
"active": "Active",
"responseTime": "Response time"
"active": "Active"
}
}
},
@@ -375,8 +372,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
"statusCode": "Status code",
"location": "Location"
"dateTime": "Date & time",
"statusCode": "Status code"
}
}
},
@@ -401,23 +398,12 @@
},
"status": {
"down": "down",
"breached": "threshold exceeded",
"breached": "breached",
"initializing": "initializing",
"maintenance": "maintenance",
"paused": "paused",
"total": "total",
"up": "up"
},
"monitorTypes": {
"optionDocker": "Docker",
"optionGame": "Game",
"optionHttp": "HTTP(S)",
"optionPing": "Ping",
"optionPort": "Port",
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
}
}
},
@@ -445,6 +431,31 @@
},
"title": "Advanced settings"
},
"geoChecks": {
"title": "Geo-distributed checks",
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
"option": {
"enabled": {
"label": "Enable geo-distributed checks"
},
"locations": {
"label": "Locations",
"placeholder": "Select locations",
"options": {
"EU": "Europe",
"NA": "North America",
"AS": "Asia",
"SA": "South America",
"AF": "Africa",
"OC": "Oceania"
}
},
"interval": {
"label": "Check interval (minutes)",
"helper": "Minimum 5 minutes between geo checks"
}
}
},
"frequency": {
"description": "How often do you want to check the status of this monitor?",
"option": {
@@ -495,19 +506,6 @@
"port": {
"label": "Port to monitor",
"placeholder": 80
},
"grpcServiceName": {
"label": "Service name",
"placeholder": "e.g. my.service.v1 (leave empty for overall health)"
},
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"strategy": {
"label": "Strategy",
"desktop": "Desktop",
"mobile": "Mobile"
}
},
"title": "General settings",
@@ -518,8 +516,6 @@
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
@@ -550,12 +546,15 @@
},
"type": {
"description": "Select the type of check to perform",
"optionDocker": "Docker",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
"optionHttp": "HTTP(S)",
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPing": "Ping",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPort": "Port",
"optionPortDescription": "Monitor if a specific port on a server is open.",
"title": "Type"
},
@@ -577,36 +576,6 @@
}
}
},
"geoChecks": {
"title": "Geo-Distributed Checks",
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
"option": {
"enabled": {
"label": "Enable geo-distributed checks"
},
"locations": {
"label": "Locations",
"placeholder": "Select locations",
"options": {
"EU": "Europe",
"NA": "North America",
"AS": "Asia",
"SA": "South America",
"AF": "Africa",
"OC": "Oceania"
}
},
"interval": {
"label": "Check interval",
"value": {
"fiveMinutes": "5 minutes",
"tenMinutes": "10 minutes",
"fifteenMinutes": "15 minutes",
"thirtyMinutes": "30 minutes"
}
}
}
},
"url": {
"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.",
@@ -636,12 +605,6 @@
"title": "Roles",
"description": "Assign roles to the user. Multiple roles can be selected."
}
},
"dialog": {
"removeUser": {
"title": "Remove user",
"content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone."
}
}
},
"incidents": {
@@ -682,6 +645,7 @@
},
"filters": {
"allMonitors": "All monitors",
"clearFilters": "Clear filters",
"monitor": "Monitor",
"resolutionType": "Resolution type",
"resolutionTypes": {
@@ -956,14 +920,6 @@
"description": "Select the type of notification channel to create.",
"optionType": "Type",
"title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
}
},
"table": {
@@ -973,10 +929,6 @@
}
},
"pageSpeed": {
"strategy": {
"desktop": "Desktop",
"mobile": "Mobile"
},
"charts": {
"common": {
"cls": "Cumulative Layout Shift (CLS)",
@@ -1062,7 +1014,7 @@
},
"stats": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
"description": "Define how long you want to retain historical data. You can also clear all existing data.",
"option": {
"clear": {
"label": "Clear all stats. This is irreversible."
@@ -1073,16 +1025,6 @@
"description": "This cannot be undone"
}
},
"retention": {
"title": "Check retention",
"description": "Set how long check data is retained before being automatically cleaned up.",
"option": {
"days": {
"label": "Retention period (days)",
"unlimited": "Unlimited"
}
}
},
"thresholds": {
"title": "Global Thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
@@ -1179,9 +1121,6 @@
"about": {
"title": "About",
"developedBy": "Developed by Bluewave Labs"
},
"validation": {
"errorMessage": "Please fix the following validation errors:"
}
}
},
@@ -1199,22 +1138,7 @@
},
"monitorsList": {
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram",
"noData": "No data available",
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
"memory": "RAM",
"memoryText": "Memory",
"disk": "Disk",
"usage": "Usage",
"used": "Used",
"total": "Total"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
}
"chartTypeHistogram": "Histogram"
},
"statusBar": {
"allDown": "All systems are down",
@@ -1306,9 +1230,6 @@
},
"showAdminLoginLink": {
"label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
let version = "3.5.1";
let version = "3.4.0";
return {
base: "/",
-26
View File
@@ -1,26 +0,0 @@
# ==============================================
# Checkmate Server Configuration
# ==============================================
# Copy this file to .env and fill in your values
# Server Configuration
# --------------------
NODE_ENV=development
LOG_LEVEL=debug
# Database
# --------
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
# JWT Authentication
# ------------------
JWT_SECRET=my_secret
TOKEN_TTL=99d
# Client Configuration
# --------------------
CLIENT_HOST=http://localhost:5173
# Optional Configuration
# ----------------------
ORIGIN=localhost
-17
View File
@@ -1,7 +1,6 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import mochaPlugin from "eslint-plugin-mocha";
import tseslint from "typescript-eslint";
/*
Please do not forget to look at the latest eslint configurations and rules.
@@ -11,9 +10,6 @@ ESlint v9 configuration is different than v8.
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ["dist/**", "node_modules/**", "coverage/**", "test/**"],
},
{
languageOptions: {
globals: {
@@ -25,22 +21,9 @@ export default [
},
},
pluginJs.configs.recommended, // Core JS rules
...tseslint.configs.recommended, // TypeScript recommended rules
mochaPlugin.configs.flat.recommended, // Mocha rules
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: {
allowDefaultProject: ["jest.config.ts"],
},
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
"@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
},
},
+175 -183
View File
@@ -432,6 +432,46 @@
}
},
"/monitors": {
"get": {
"tags": ["monitors"],
"summary": "Get all monitors",
"description": "Retrieve all monitors for the authenticated user",
"responses": {
"200": {
"description": "Monitors retrieved successfully",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/SuccessResponse"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Monitor"
}
}
}
}
]
}
}
}
},
"500": {
"$ref": "#/components/responses/InternalServerError"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": ["monitors"],
"summary": "Create monitor",
@@ -510,24 +550,42 @@
"summary": "Get monitors by team",
"description": "Get monitors filtered by team with optional parameters",
"parameters": [
{
"name": "status",
"in": "query",
"description": "Filter by monitor status (up/down)",
"schema": {
"type": "boolean"
}
},
{
"name": "type",
"in": "query",
"description": "Filter by monitor type",
"schema": {
"oneOf": [
{
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
},
{
"type": "array",
"items": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
}
}
]
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"]
}
},
{
"name": "page",
"in": "query",
"description": "Page number for pagination",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "rowsPerPage",
"in": "query",
"description": "Number of monitors per page",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
}
},
{
@@ -537,6 +595,15 @@
"schema": {
"type": "string"
}
},
{
"name": "field",
"in": "query",
"description": "Field to filter on",
"schema": {
"type": "string",
"enum": ["name", "url", "description"]
}
}
],
"responses": {
@@ -729,7 +796,7 @@
}
]
},
"patch": {
"put": {
"tags": ["monitors"],
"summary": "Update monitor",
"description": "Update an existing monitor",
@@ -3541,19 +3608,22 @@
},
"CreateMonitorRequest": {
"type": "object",
"required": ["name", "type", "url"],
"required": ["name", "description", "type", "url", "interval"],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"example": "My Website Monitor"
},
"description": {
"type": "string",
"maxLength": 500,
"example": "Monitors the main website homepage"
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"],
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"],
"example": "http"
},
"url": {
@@ -3563,34 +3633,14 @@
},
"interval": {
"type": "integer",
"minimum": 30,
"maximum": 3600,
"example": 300,
"description": "Check interval in seconds"
},
"statusWindowSize": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
},
"statusWindowThreshold": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 60
},
"isActive": {
"type": "boolean"
},
"ignoreTlsErrors": {
"type": "boolean",
"default": false
},
"useAdvancedMatching": {
"type": "boolean",
"default": false
},
"port": {
"type": "integer"
"default": true
},
"notifications": {
"type": "array",
@@ -3599,68 +3649,51 @@
},
"description": "Array of notification IDs to associate with this monitor"
},
"secret": {
"type": "string"
},
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex", ""]
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string",
"default": ""
},
"strategy": {
"type": "string",
"enum": ["desktop", "mobile"],
"default": "desktop",
"description": "PageSpeed analysis strategy"
},
"selectedDisks": {
"type": "array",
"items": {
"type": "string"
"httpOptions": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"],
"default": "GET"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "string",
"description": "Request body for POST/PUT requests"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"maximum": 30000,
"default": 5000,
"description": "Request timeout in milliseconds"
}
}
},
"group": {
"type": "string",
"nullable": true,
"maxLength": 50
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"assertions": {
"type": "array",
"items": {
"type": "string",
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
},
"comparison": {
"type": "string",
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
},
"value": {
"type": "string"
}
}
}
},
"geoCheckInterval": {
"type": "integer",
"minimum": 300000
}
}
},
@@ -3668,32 +3701,21 @@
"type": "object",
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
},
"url": {
"type": "string"
},
"statusWindowSize": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
},
"statusWindowThreshold": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 60
"minLength": 1,
"maxLength": 100
},
"description": {
"type": "string"
"type": "string",
"maxLength": 500
},
"interval": {
"type": "integer"
"type": "integer",
"minimum": 30,
"maximum": 3600
},
"isActive": {
"type": "boolean"
},
"notifications": {
"type": "array",
@@ -3701,77 +3723,47 @@
"type": "string"
}
},
"secret": {
"type": "string"
},
"ignoreTlsErrors": {
"type": "boolean"
},
"useAdvancedMatching": {
"type": "boolean"
},
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex", ""]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string"
},
"strategy": {
"type": "string",
"enum": ["desktop", "mobile"],
"description": "PageSpeed analysis strategy"
},
"selectedDisks": {
"type": "array",
"items": {
"type": "string"
"httpOptions": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"]
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "string"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"maximum": 30000
}
}
},
"group": {
"type": "string",
"nullable": true,
"maxLength": 50
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"assertions": {
"type": "array",
"items": {
"type": "string",
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
},
"comparison": {
"type": "string",
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
},
"value": {
"type": "string"
}
}
}
},
"geoCheckInterval": {
"type": "integer",
"minimum": 300000
}
}
},
+834 -1292
View File
File diff suppressed because it is too large. Load diff
+7 -16
View File
@@ -8,14 +8,14 @@
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
"build": "tsc && tsc-alias",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "prettier --write .",
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "npm run format"
},
"keywords": [],
"author": "",
@@ -24,8 +24,6 @@
"node": ">=20"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
@@ -38,29 +36,27 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"gamedig": "5.2.0",
"gamedig": "^5.3.1",
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"joi": "^17.13.1",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"nodemailer": "^6.9.14",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
"winston": "^3.13.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -78,12 +74,8 @@
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/pg": "8.20.0",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
@@ -97,7 +89,6 @@
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
"typescript": "5.9.2",
"typescript-eslint": "^8.56.1"
"typescript": "5.9.2"
}
}
+3 -1
View File
@@ -3,6 +3,8 @@ import { MonitorModel } from "../dist/db/models/Monitor.js";
import { CheckModel } from "../dist/db/models/Check.js";
const DEFAULT_MONITOR_ID = "000000000000000000000001";
const DEFAULT_TEAM_ID = "0000000000000000000000aa";
const DEFAULT_USER_ID = "0000000000000000000000bb";
const DEFAULT_MONITOR_TYPE = "http";
const DEFAULT_TOTAL = 1_000_000;
const DEFAULT_BATCH_SIZE = 5_000;
@@ -10,7 +12,7 @@ const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
} catch {
} catch (error) {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
+4 -18
View File
@@ -4,7 +4,7 @@ import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import cookieParser from "cookie-parser";
import swaggerUi, { type JsonObject } from "swagger-ui-express";
import swaggerUi from "swagger-ui-express";
import { handleErrors } from "@/middleware/handleErrors.js";
import { generalApiLimiter } from "@/middleware/rateLimiter.js";
import { sanitizeBody, sanitizeQuery } from "@/middleware/sanitization.js";
@@ -24,7 +24,7 @@ export const createApp = ({
controllers: InitializedControllers;
envSettings: EnvConfig;
frontendPath: string;
openApiSpec: JsonObject;
openApiSpec: any;
}) => {
const allowedOrigin = envSettings.clientHost;
const app = express();
@@ -74,22 +74,8 @@ export const createApp = ({
},
})
);
// Swagger UI — dynamically set server URL from request
app.use("/api-docs", swaggerUi.serve, (req: express.Request, res: express.Response, next: express.NextFunction) => {
const protocol = req.protocol;
const host = req.get("host");
const dynamicSpec = {
...openApiSpec,
servers: [
{
url: `${protocol}://${host}/api/v1`,
description: "Current Server",
},
...openApiSpec.servers,
],
};
swaggerUi.setup(dynamicSpec)(req, res, next);
});
// Swagger UI
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
app.use("/api/v1/health", (req, res) => {
res.json({
+1 -4
View File
@@ -2,7 +2,6 @@ import MonitorController from "../controllers/monitorController.js";
import AuthController from "../controllers/authController.js";
import SettingsController from "../controllers/settingsController.js";
import CheckController from "../controllers/checkController.js";
import GeoCheckController from "../controllers/geoCheckController.js";
import InviteController from "../controllers/inviteController.js";
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
import QueueController from "../controllers/queueController.js";
@@ -18,7 +17,6 @@ export interface InitializedControllers {
monitorController: MonitorController;
settingsController: SettingsController;
checkController: CheckController;
geoCheckController: GeoCheckController;
inviteController: InviteController;
maintenanceWindowController: MaintenanceWindowController;
queueController: QueueController;
@@ -31,10 +29,9 @@ export interface InitializedControllers {
export const initializeControllers = (services: InitializedServices): InitializedControllers => {
return {
authController: new AuthController(services.userService),
monitorController: new MonitorController(services.monitorService, services.notificationsService),
monitorController: new MonitorController(services.monitorService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
geoCheckController: new GeoCheckController(services.geoChecksService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
+1 -7
View File
@@ -1,15 +1,11 @@
import type { Application } from "express";
import { createVerifyJWT } from "../middleware/verifyJWT.js";
import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js";
import { authApiLimiter } from "../middleware/rateLimiter.js";
import type { InitializedControllers } from "./controllers.js";
import type { InitializedServices } from "./services.js";
import AuthRoutes from "../routes/authRoute.js";
import InviteRoutes from "../routes/inviteRoute.js";
import MonitorRoutes from "../routes/monitorRoute.js";
import CheckRoutes from "../routes/checkRoute.js";
import GeoCheckRoutes from "../routes/geoCheckRoutes.js";
import SettingsRoutes from "../routes/settingsRoute.js";
import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js";
import StatusPageRoutes from "../routes/statusPageRoute.js";
@@ -20,13 +16,12 @@ import NotificationRoutes from "../routes/notificationRoute.js";
import IncidentRoutes from "../routes/incidentRoute.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => {
export const setupRoutes = (app: any, controllers: Record<string, any>, services: Record<string, any>) => {
const verifyJWT = createVerifyJWT(services.settingsService);
const authRoutes = new AuthRoutes(controllers.authController, verifyJWT);
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
const checkRoutes = new CheckRoutes(controllers.checkController);
const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController);
const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT);
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
const queueRoutes = new QueueRoutes(controllers.queueController);
@@ -41,7 +36,6 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter());
app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter());
app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter());
app.use("/api/v1/invite", inviteRoutes.getRouter());
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter());
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
+82 -206
View File
@@ -1,72 +1,34 @@
import MongoDB from "../db/MongoDB.js";
import { IDb } from "@/db/IDb.js";
import NetworkService from "../service/infrastructure/networkService.js";
import EmailService from "../service/infrastructure/emailService.js";
import BufferService from "../service/infrastructure/bufferService.js";
import {
// Service classes
NetworkService,
EmailService,
BufferService,
GlobalPingService,
SuperSimpleQueue,
SuperSimpleQueueHelper,
NotificationsService,
StatusService,
NotificationMessageBuilder,
MonitorService,
StatusPageService,
UserService,
CheckService,
GeoChecksService,
DiagnosticService,
InviteService,
MaintenanceWindowService,
IncidentService,
// Notification providers
WebhookProvider,
SlackProvider,
EmailProvider,
DiscordProvider,
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
// Interfaces
INetworkService,
IEmailService,
IBufferService,
ISuperSimpleQueue,
INotificationsService,
IStatusService,
IMonitorService,
IUserService,
ICheckService,
IGeoChecksService,
IDiagnosticService,
IInviteService,
IMaintenanceWindowService,
IStatusPageService,
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
SettingsService,
EnvConfig,
} from "@/service/index.js";
// Network providers
import { PingProvider } from "@/service/infrastructure/network/PingProvider.js";
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js";
import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js";
import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
// Third-party
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
import { MonitorService } from "@/service/index.js";
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
import IncidentService from "../service/business/incidentService.js";
import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js";
import axios from "axios";
import got from "got";
import ping from "ping";
import http from "http";
import https from "https";
import Docker from "dockerode";
import net from "net";
import fs from "fs";
@@ -79,41 +41,22 @@ import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
// Repositories
// repositories
import {
MongoMonitorsRepository,
MongoChecksRepository,
MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
MongoInvitesRepository,
MongoRecoveryTokensRepository,
MongoNotificationsRepository,
MongoIncidentsRepository,
MongoIncidentRepository,
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -126,26 +69,24 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import TimescaleDB from "@/db/TimescaleDB.js";
import { AppError } from "@/utils/AppError.js";
import { EnvConfig } from "@/service/system/settingsService.js";
export type InitializedServices = {
settingsService: ISettingsService;
db: IDb;
networkService: INetworkService;
emailService: IEmailService;
bufferService: IBufferService;
statusService: IStatusService;
jobQueue: ISuperSimpleQueue;
userService: IUserService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
diagnosticService: IDiagnosticService;
inviteService: IInviteService;
maintenanceWindowService: IMaintenanceWindowService;
monitorService: IMonitorService;
incidentService: IIncidentService;
logger: ILogger;
settingsService: any;
db: any;
networkService: any;
emailService: any;
bufferService: any;
statusService: any;
jobQueue: any;
userService: any;
checkService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
monitorService: any;
incidentService: any;
logger: any;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
@@ -153,7 +94,6 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -170,134 +110,73 @@ export const initializeServices = async ({
logger,
envSettings,
settingsService,
settingsRepository,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
settingsService: any;
settingsRepository: ISettingsRepository;
}): Promise<InitializedServices> => {
// Create DB
const dbType = envSettings.dbType;
let db: IDb | null = null;
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
throw new AppError({ message: "Unsupported database type", status: 500 });
}
const db = new MongoDB(logger, envSettings);
await db.connect();
let monitorsRepository: IMonitorsRepository;
let checksRepository: IChecksRepository;
let geoChecksRepository: IGeoChecksRepository;
let monitorStatsRepository: IMonitorStatsRepository;
let statusPagesRepository: IStatusPagesRepository;
let usersRepository: IUsersRepository;
let invitesRepository: IInvitesRepository;
let recoveryTokensRepository: IRecoveryTokensRepository;
let settingsRepository: ISettingsRepository;
let notificationsRepository: INotificationsRepository;
let incidentsRepository: IIncidentsRepository;
let teamsRepository: ITeamsRepository;
let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
const invitesRepository = new MongoInvitesRepository();
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
const notificationsRepository = new MongoNotificationsRepository();
const incidentsRepository = new MongoIncidentRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
if (dbType === "mongodb") {
monitorsRepository = new MongoMonitorsRepository();
checksRepository = new MongoChecksRepository(logger);
geoChecksRepository = new MongoGeoChecksRepository(logger);
monitorStatsRepository = new MongoMonitorStatsRepository();
statusPagesRepository = new MongoStatusPagesRepository();
usersRepository = new MongoUsersRepository();
invitesRepository = new MongoInvitesRepository();
recoveryTokensRepository = new MongoRecoveryTokensRepository();
settingsRepository = new MongoSettingsRepository();
notificationsRepository = new MongoNotificationsRepository();
incidentsRepository = new MongoIncidentsRepository();
teamsRepository = new MongoTeamsRepository();
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
} else {
const pool = db.getPool();
if (!pool) {
throw new Error("Failed to get database pool");
}
monitorsRepository = new TimescaleMonitorsRepository(pool);
checksRepository = new TimescaleChecksRepository(pool);
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
usersRepository = new TimescaleUsersRepository(pool);
invitesRepository = new TimescaleInvitesRepository(pool);
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
settingsRepository = new TimescaleSettingsRepository(pool);
notificationsRepository = new TimescaleNotificationsRepository(pool);
incidentsRepository = new TimescaleIncidentsRepository(pool);
teamsRepository = new TimescaleTeamsRepository(pool);
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
}
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
// Network providers
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const networkService = new NetworkService(axios, logger, [
pingProvider,
httpProvider,
pageSpeedProvider,
hardwareProvider,
dockerProvider,
portProvider,
gameProvider,
grpcProvider,
webSocketProvider,
]);
const networkService = new NetworkService({
axios,
got,
https,
jmespath,
GameDig,
ping,
logger,
http,
Docker,
net,
settingsService,
});
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
const checkService = new CheckService(monitorsRepository, logger, checksRepository);
const globalPingService = new GlobalPingService(logger);
const geoChecksService = new GeoChecksService({
const incidentService = new IncidentService({
logger,
geoChecksRepository,
globalPingService,
incidentsRepository,
monitorsRepository,
usersRepository,
notificationMessageBuilder,
});
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const checkService = new CheckService({
monitorsRepository,
logger,
checksRepository,
});
const bufferService = new BufferService({ logger, checkService, settingsService });
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
// Notification providers
const webhookProvider = new WebhookProvider(logger);
const slackProvider = new SlackProvider(logger);
const emailProvider = new EmailProvider(emailService, logger);
const discordProvider = new DiscordProvider(logger);
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -308,21 +187,18 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
logger,
notificationMessageBuilder
);
const superSimpleQueueHelper = new SuperSimpleQueueHelper(
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
logger,
networkService,
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
buffer: bufferService,
incidentService,
maintenanceWindowsRepository,
monitorsRepository,
@@ -330,11 +206,13 @@ export const initializeServices = async ({
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService,
geoChecksRepository
);
});
const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
const superSimpleQueue = await SuperSimpleQueue.create({
logger,
helper: superSimpleQueueHelper,
monitorsRepository,
});
// Business services
const userService = new UserService({
@@ -369,7 +247,6 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -378,6 +255,7 @@ export const initializeServices = async ({
const statusPageService = new StatusPageService(statusPagesRepository);
const services = {
//v1
settingsService,
db,
networkService,
@@ -387,7 +265,6 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -401,7 +278,6 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
+54 -85
View File
@@ -1,51 +1,31 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { requireTeamId, requireUserRoles } from "./controllerUtils.js";
import type { UserRole } from "@/types/user.js";
import {
registrationBodyValidation,
loginValidation,
editUserBodyValidation,
createUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
import {
editUserBodyValidation,
createUserBodyValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/userValidation.js";
import { IUserService } from "@/service/index.js";
} from "@/validation/joi.js";
const SERVICE_NAME = "authController";
export interface IAuthController {
registerUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
createUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
loginUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
checkSuperadminExists(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
requestRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
validateRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
resetPassword(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getAllUsers(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUserPasswordById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class AuthController implements IAuthController {
class AuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: IUserService;
private userService: any;
constructor(userService: IUserService) {
constructor(userService: any) {
this.userService = userService;
}
@@ -58,12 +38,11 @@ class AuthController implements IAuthController {
const newUser = req.body.user;
const newUserToken = req.body.token;
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
newUser.email = newUser.email.toLowerCase();
}
const validatedBody = registrationBodyValidation.parse(newUser);
await registrationBodyValidation.validateAsync(newUser);
const { user, token } = await this.userService.registerUser(validatedBody, newUserToken, req?.file ?? null);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
res.status(200).json({
success: true,
msg: "User registered successfully",
@@ -80,11 +59,11 @@ class AuthController implements IAuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
const validatedBody = createUserBodyValidation.parse(userData);
await createUserBodyValidation.validateAsync(userData);
const teamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role);
const newUser = await this.userService.createUser(validatedBody, teamId, actorRoles, req?.file ?? null);
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file);
res.status(201).json({
success: true,
msg: "User created successfully",
@@ -100,7 +79,7 @@ class AuthController implements IAuthController {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
loginValidation.parse(req.body);
await loginValidation.validateAsync(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
@@ -118,10 +97,8 @@ class AuthController implements IAuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = editUserBodyValidation.parse(req.body);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const updatedUser = await this.userService.editUser(validatedBody, req?.file ?? null, userId, userEmail);
await editUserBodyValidation.validateAsync(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
success: true,
@@ -148,7 +125,7 @@ class AuthController implements IAuthController {
requestRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
recoveryValidation.parse(req.body);
await recoveryValidation.validateAsync(req.body);
const email = req?.body?.email;
const msgId = await this.userService.requestRecovery(email);
return res.status(200).json({
@@ -163,7 +140,7 @@ class AuthController implements IAuthController {
validateRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
recoveryTokenBodyValidation.parse(req.body);
await recoveryTokenBodyValidation.validateAsync(req.body);
await this.userService.validateRecovery(req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -176,7 +153,7 @@ class AuthController implements IAuthController {
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
newPasswordValidation.parse(req.body);
await newPasswordValidation.validateAsync(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -190,15 +167,7 @@ class AuthController implements IAuthController {
deleteUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const roles = requireUserRoles(req.user?.role);
await this.userService.deleteUser({
userId,
teamId,
roles,
});
await this.userService.deleteUser(req.user);
return res.status(200).json({
success: true,
msg: "User deleted successfully",
@@ -208,23 +177,6 @@ class AuthController implements IAuthController {
}
};
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUserByIdParamValidation.parse(req.params);
const targetUserId = validatedParams.userId;
const actorId = requireUserId(req.user?.id);
const actorTeamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role);
await this.userService.deleteUserById({ actorId, actorTeamId, actorRoles, targetUserId });
return res.status(200).json({
success: true,
msg: "User removed successfully",
});
} catch (error) {
next(error);
}
};
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
try {
const allUsers = await this.userService.getAllUsers();
@@ -240,9 +192,19 @@ class AuthController implements IAuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUserByIdParamValidation.parse(req.params);
const actorRoles = requireUserRoles(req.user?.role);
const user = await this.userService.getUserById(actorRoles, validatedParams.userId);
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.userService.getUserById(roles, userId);
return res.status(200).json({ success: true, msg: "ok", data: user });
} catch (error) {
@@ -252,19 +214,24 @@ class AuthController implements IAuthController {
editUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorRoles = requireUserRoles(req.user?.role);
const actorId = requireUserId(req.user?.id);
const roles = req?.user?.role;
if (!actorRoles.includes("superadmin")) {
if (!roles || !roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const validatedParams = editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
const validatedBody =
validatedParams.userId === actorId ? editSuperadminUserByIdBodyValidation.parse(req.body) : editUserByIdBodyValidation.parse(req.body);
const userId = req.params.userId as string;
const user = { ...req.body };
await this.userService.editUserById(validatedParams.userId, validatedBody);
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.userService.editUserById(userId, user);
return res.status(200).json({ success: true, msg: "ok" });
} catch (error) {
next(error);
@@ -273,14 +240,16 @@ class AuthController implements IAuthController {
editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorRoles = requireUserRoles(req.user?.role);
if (!actorRoles.includes("superadmin")) {
const roles = req?.user?.role;
if (!roles || !roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const validatedParams = editUserByIdParamValidation.parse(req.params);
const validatedBody = editUserPasswordByIdBodyValidation.parse(req.body);
await this.userService.setPasswordByUserId(validatedParams.userId, validatedBody.password);
const userId = req.params.userId as string;
await editUserByIdParamValidation.validateAsync(req.params);
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
return res.status(200).json({ success: true, msg: "Password reset successfully" });
} catch (error) {
next(error);
+42 -48
View File
@@ -4,28 +4,21 @@ import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
getChecksSummaryByTeamIdQueryValidation,
deleteChecksParamValidation,
} from "@/validation/checkValidation.js";
import { ICheckService } from "@/service/index.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
ackCheckBodyValidation,
ackAllChecksParamValidation,
ackAllChecksBodyValidation,
} from "@/validation/joi.js";
const SERVICE_NAME = "checkController";
export interface ICheckController {
serviceName: string;
getChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getChecksByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getChecksSummaryByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteChecks: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class CheckController implements ICheckController {
class CheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: ICheckService;
constructor(checkService: ICheckService) {
private checkService: any;
constructor(checkService: any) {
this.checkService = checkService;
}
@@ -35,20 +28,13 @@ class CheckController implements ICheckController {
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
const result = await this.checkService.getChecksByMonitor({
monitorId: validatedParams.monitorId,
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
filter: validatedQuery.filter,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
status: validatedQuery.status,
monitorId: req?.params?.monitorId,
query: req?.query,
teamId: req?.user?.teamId,
});
return res.status(200).json({
@@ -63,16 +49,10 @@ class CheckController implements ICheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getTeamChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
await getTeamChecksQueryValidation.validateAsync(req.query);
const checkData = await this.checkService.getChecksByTeam({
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
filter: validatedQuery.filter,
teamId: req?.user?.teamId,
query: req?.query,
});
return res.status(200).json({
success: true,
@@ -86,11 +66,8 @@ class CheckController implements ICheckController {
getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getChecksSummaryByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const dateRange = validatedQuery.dateRange ?? "hour";
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId, dateRange });
const dateRange = req.query?.dateRange || "hour";
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange });
return res.status(200).json({
success: true,
msg: "Checks summary retrieved successfully",
@@ -103,12 +80,11 @@ class CheckController implements ICheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = deleteChecksParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
await deleteChecksParamValidation.validateAsync(req.params);
const deletedCount = await this.checkService.deleteChecks({
monitorId: validatedParams.monitorId,
teamId,
monitorId: req.params.monitorId as string,
teamId: req?.user?.teamId,
});
return res.status(200).json({
@@ -123,9 +99,9 @@ class CheckController implements ICheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId });
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
return res.status(200).json({
success: true,
@@ -136,6 +112,24 @@ class CheckController implements ICheckController {
next(error);
}
};
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
await updateChecksTTLBodyValidation.validateAsync(req.body);
await this.checkService.updateChecksTTL({
teamId: req?.user?.teamId,
ttl: req?.body?.ttl,
});
return res.status(200).json({
success: true,
msg: "Checks TTL updated successfully",
});
} catch (error) {
next(error);
}
};
}
export default CheckController;
+27 -22
View File
@@ -1,25 +1,24 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
import { type MonitorType, MonitorTypes } from "@/types/index.js";
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await checker(hostname);
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
return cert;
};
export const requireString = (value: unknown, fieldName: string): string => {
const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
throw new AppError({ message: `${fieldName} is required`, status: 400 });
};
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
@@ -29,7 +28,7 @@ export const optionalString = (value: unknown, fieldName: string): string | unde
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
@@ -45,7 +44,7 @@ export const optionalNumber = (value: unknown, fieldName: string): number | unde
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
@@ -63,7 +62,7 @@ export const optionalBoolean = (value: unknown, fieldName: string): boolean | un
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
throw new AppError({ message: "Monitor type must be a string", status: 400 });
@@ -83,7 +82,7 @@ export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorTyp
return parseSingle(value);
};
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
@@ -93,36 +92,42 @@ export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
export const requireTeamId = (teamId?: string): string => {
const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
return teamId;
};
export const requireUserId = (userId?: string): string => {
const requireUserId = (userId?: string): string => {
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
return userId;
};
export const requireUserEmail = (userEmail?: string): string => {
const requireUserEmail = (userEmail?: string): string => {
if (!userEmail) {
throw new AppError({ message: "User email is required", status: 400 });
}
return userEmail;
};
export const requireFirstName = (firstName?: string): string => {
if (!firstName) {
throw new AppError({ message: "First name is required", status: 400 });
}
return firstName;
};
export const requireUserRoles = (userRoles?: UserRole[]): UserRole[] => {
export const requireUserRoles = (userRoles?: string[]): string[] => {
if (!userRoles || userRoles.length === 0) {
throw new AppError({ message: "User roles are required", status: 400 });
}
return userRoles;
};
export {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
requireUserEmail,
};
@@ -1,18 +1,12 @@
import { IDiagnosticService } from "@/service/index.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "diagnosticController";
export interface IDiagnosticController {
getSystemStats: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class DiagnosticController implements IDiagnosticController {
class DiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: IDiagnosticService;
private diagnosticService: any;
constructor(diagnosticService: IDiagnosticService) {
constructor(diagnosticService: any) {
this.diagnosticService = diagnosticService;
}
@@ -1,52 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import { requireTeamId } from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "geoCheckController";
export interface IGeoCheckController {
getGeoChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class GeoCheckController implements IGeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService;
constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService;
}
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const result = await this.geoChecksService.getGeoChecksByMonitor({
monitorId: validatedParams.monitorId,
teamId: teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
continent: validatedQuery.continent,
});
return res.status(200).json({
success: true,
msg: "Geo checks retrieved successfully",
data: result,
});
} catch (error) {
next(error);
}
};
}
export default GeoCheckController;
+21 -22
View File
@@ -1,37 +1,35 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
const SERVICE_NAME = "IncidentController";
const SERVICE_NAME = "incidentController";
export interface IIncidentController {
getIncidentsByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getIncidentSummary: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getIncidentById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
resolveIncidentManually: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class IncidentController implements IIncidentController {
private incidentService: IIncidentService;
constructor(incidentService: IIncidentService) {
class IncidentController {
static SERVICE_NAME = SERVICE_NAME;
private incidentService: any;
constructor(incidentService: any) {
this.incidentService = incidentService;
}
get serviceName() {
return IncidentController.SERVICE_NAME;
}
getIncidentsByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getIncidentsByTeamQueryValidation.parse(req.query);
const { sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType } = req.query || {};
const teamId = requireTeamId(req.user?.teamId);
const result = await this.incidentService.getIncidentsByTeam(
teamId,
validatedQuery.sortOrder,
validatedQuery.dateRange,
validatedQuery.page,
validatedQuery.rowsPerPage,
validatedQuery.status,
validatedQuery.monitorId,
validatedQuery.resolutionType
sortOrder,
dateRange,
page,
rowsPerPage,
status,
monitorId,
resolutionType
);
return res.status(200).json({
@@ -47,9 +45,10 @@ class IncidentController implements IIncidentController {
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedQuery = getIncidentSummaryQueryValidation.parse(req.query);
const summary = await this.incidentService.getIncidentSummary(teamId, validatedQuery.limit);
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
const summary = await this.incidentService.getIncidentSummary(teamId, limit);
return res.status(200).json({
success: true,
msg: "Incident summary retrieved successfully",
+11 -19
View File
@@ -1,19 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { IInviteService } from "@/service/index.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "inviteController";
export interface IInviteController {
getInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
sendInviteEmail: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
verifyInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class InviteController implements IInviteController {
class InviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: IInviteService;
constructor(inviteService: IInviteService) {
private inviteService: any;
constructor(inviteService: any) {
this.inviteService = inviteService;
}
@@ -27,7 +20,7 @@ class InviteController implements IInviteController {
const userRoles = requireUserRoles(req.user?.role);
const invite = req.body;
invite.teamId = teamId;
inviteBodyValidation.parse(invite);
await inviteBodyValidation.validateAsync(invite);
const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles });
return res.status(200).json({
success: true,
@@ -41,17 +34,16 @@ class InviteController implements IInviteController {
sendInviteEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
const firstName = requireFirstName(req.user?.firstName);
const teamId = requireTeamId(req?.user?.teamId);
const userRoles = requireUserRoles(req?.user?.role);
const inviteRequest = req.body;
inviteRequest.teamId = teamId;
inviteBodyValidation.parse(inviteRequest);
await inviteBodyValidation.validateAsync(inviteRequest);
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
firstName,
firstName: req?.user?.firstName,
userRoles,
});
return res.status(200).json({
@@ -66,7 +58,7 @@ class InviteController implements IInviteController {
verifyInviteToken = async (req: Request, res: Response, next: NextFunction) => {
try {
inviteVerificationBodyValidation.parse(req.body);
await inviteVerificationBodyValidation.validateAsync(req.body);
const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token });
return res.status(200).json({
success: true,
+3 -8
View File
@@ -1,16 +1,11 @@
import { ILogger } from "@/utils/logger.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "LogController";
export interface ILogController {
getLogs: (req: Request, res: Response, next: NextFunction) => Promise<void>;
}
class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
private logger: any;
constructor(logger: any) {
this.logger = logger;
}
get serviceName() {
@@ -19,7 +14,7 @@ class LogController {
getLogs = async (req: Request, res: Response, next: NextFunction) => {
try {
const logs = this.logger.getLogs();
const logs = await this.logger.getLogs();
res.status(200).json({
success: true,
msg: "Logs fetched successfully",
@@ -7,39 +7,28 @@ import {
getMaintenanceWindowsByMonitorIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
} from "@/validation/maintenanceWindowValidation.js";
} from "@/validation/joi.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
import { IMaintenanceWindowService } from "@/service/index.js";
export interface IMaintenanceWindowController {
createMaintenanceWindows: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowsByMonitorId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteMaintenanceWindow: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMaintenanceWindow: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class MaintenanceWindowController implements IMaintenanceWindowController {
private maintenanceWindowService: IMaintenanceWindowService;
constructor(maintenanceWindowService: IMaintenanceWindowService) {
const SERVICE_NAME = "maintenanceWindowController";
class MaintenanceWindowController {
static SERVICE_NAME = SERVICE_NAME;
private maintenanceWindowService: any;
constructor(maintenanceWindowService: any) {
this.maintenanceWindowService = maintenanceWindowService;
}
get serviceName() {
return MaintenanceWindowController.SERVICE_NAME;
}
createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createMaintenanceWindowBodyValidation.parse(req.body);
await createMaintenanceWindowBodyValidation.validateAsync(req.body);
const teamId = requireTeamId(req?.user?.teamId);
const monitorIDs = validatedBody.monitors;
const name = validatedBody.name;
const active = validatedBody.active ?? true;
const duration = validatedBody.duration;
const durationUnit = validatedBody.durationUnit;
const repeat = validatedBody.repeat;
const start = validatedBody.start;
const end = validatedBody.end;
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, monitorIDs, name, active, duration, durationUnit, repeat, start, end });
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body });
return res.status(200).json({
success: true,
@@ -51,11 +40,11 @@ class MaintenanceWindowController implements IMaintenanceWindowController {
};
getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMaintenanceWindowByIdParamValidation.parse(req.params);
await getMaintenanceWindowByIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req.user?.teamId);
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: validatedParams.id, teamId });
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id as string, teamId });
return res.status(200).json({
success: true,
@@ -69,18 +58,11 @@ class MaintenanceWindowController implements IMaintenanceWindowController {
getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({
teamId,
active: validatedQuery.active,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
field: validatedQuery.field,
order: validatedQuery.order,
});
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({ teamId, query: req.query });
return res.status(200).json({
success: true,
@@ -94,12 +76,12 @@ class MaintenanceWindowController implements IMaintenanceWindowController {
getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({
monitorId: validatedParams.monitorId,
monitorId: req.params.monitorId as string,
teamId,
});
@@ -114,11 +96,11 @@ class MaintenanceWindowController implements IMaintenanceWindowController {
};
deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = deleteMaintenanceWindowByIdParamValidation.parse(req.params);
await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: validatedParams.id, teamId });
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id as string, teamId });
return res.status(200).json({
success: true,
@@ -131,14 +113,14 @@ class MaintenanceWindowController implements IMaintenanceWindowController {
editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = editMaintenanceWindowByIdParamValidation.parse(req.params);
const validatedBody = editMaintenanceByIdWindowBodyValidation.parse(req.body);
await editMaintenanceWindowByIdParamValidation.validateAsync(req.params);
await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body);
const teamId = requireTeamId(req.user?.teamId);
const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({
id: validatedParams.id,
body: validatedBody,
id: req.params.id as string,
body: req.body,
teamId,
});
return res.status(200).json({
+112 -159
View File
@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from "express";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
createMonitorBodyValidation,
@@ -11,48 +12,30 @@ import {
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
} from "@/validation/monitorValidation.js";
} from "@/validation/joi.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
import { IMonitorService } from "@/service/index.js";
const SERVICE_NAME = "monitorController";
export interface IMonitorController {
getMonitorCertificate: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getUptimeDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getHardwareDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getPageSpeedDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getGeoChecksByMonitorId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
createMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
importMonitorsFromJSON: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
exportMonitorsToJSON: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getAllGames: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getGroupsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
updateNotifications: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class MonitorController implements IMonitorController {
class MonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService;
private notificationsService: INotificationsService;
constructor(monitorService: IMonitorService, notificationsService: INotificationsService) {
constructor(monitorService: IMonitorService) {
this.monitorService = monitorService;
this.notificationsService = notificationsService;
}
get serviceName() {
@@ -61,9 +44,10 @@ class MonitorController implements IMonitorController {
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId });
await getCertificateParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
return res.status(200).json({
@@ -80,18 +64,16 @@ class MonitorController implements IMonitorController {
getUptimeDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange;
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const normalize = optionalBoolean(req?.query?.normalize, "normalize");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getUptimeDetailsById({
teamId,
monitorId,
dateRange,
normalize,
});
return res.status(200).json({
success: true,
@@ -105,12 +87,12 @@ class MonitorController implements IMonitorController {
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getHardwareDetailsById({
teamId,
@@ -129,12 +111,12 @@ class MonitorController implements IMonitorController {
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getPageSpeedDetailsById({
teamId,
@@ -152,40 +134,13 @@ class MonitorController implements IMonitorController {
}
};
getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const continentParam = validatedQuery.continent;
const continents = continentParam ? (Array.isArray(continentParam) ? continentParam : [continentParam]) : undefined;
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getGeoChecksByMonitorId({
teamId,
monitorId,
dateRange,
continents,
});
return res.status(200).json({
success: true,
msg: "Geo checks retrieved successfully",
data,
});
} catch (error) {
next(error);
}
};
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
@@ -201,12 +156,12 @@ class MonitorController implements IMonitorController {
createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createMonitorBodyValidation.parse(req.body);
await createMonitorBodyValidation.validateAsync(req.body);
const userId = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const userId = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.createMonitor(teamId, userId, validatedBody);
const monitor = await this.monitorService.createMonitor(teamId, userId, req.body);
return res.status(200).json({
success: true,
@@ -220,10 +175,14 @@ class MonitorController implements IMonitorController {
importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const validatedBody = importMonitorsBodyValidation.parse(req.body);
const monitors = validatedBody.monitors;
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
const { monitors } = req.body;
if (!monitors || !Array.isArray(monitors)) {
throw new AppError({ message: "Invalid request: monitors array is required", status: 400 });
}
const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors });
@@ -239,9 +198,9 @@ class MonitorController implements IMonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
await getMonitorByIdParamValidation.validateAsync(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId });
@@ -257,7 +216,8 @@ class MonitorController implements IMonitorController {
deleteAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = requireTeamId(req?.user?.teamId);
const deletedCount = await this.monitorService.deleteAllMonitors({ teamId });
return res.status(200).json({
@@ -271,12 +231,12 @@ class MonitorController implements IMonitorController {
editMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedBody = editMonitorBodyValidation.parse(req.body);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: validatedBody });
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body });
return res.status(200).json({
success: true,
@@ -290,10 +250,10 @@ class MonitorController implements IMonitorController {
pauseMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = pauseMonitorParamValidation.parse(req.params);
await pauseMonitorParamValidation.validateAsync(req.params);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId });
@@ -309,8 +269,8 @@ class MonitorController implements IMonitorController {
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const id = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const demoMonitors = await this.monitorService.addDemoMonitors({ userId: id, teamId });
return res.status(200).json({
@@ -323,13 +283,32 @@ class MonitorController implements IMonitorController {
}
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { to } = req.body;
if (!to || typeof to !== "string") {
throw new AppError({ message: "Invalid 'to' email address", status: 400 });
}
const messageId = await this.monitorService.sendTestEmail({ to });
return res.status(200).json({
success: true,
msg: "Test email sent successfully",
data: { messageId },
});
} catch (error) {
next(error);
}
};
getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMonitorsByTeamIdQueryValidation.parse(req.query);
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req.user?.teamId);
const type = validatedQuery.type;
const filter = validatedQuery.filter;
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
const filter = optionalString(req.query?.filter, "filter");
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter });
@@ -345,16 +324,17 @@ class MonitorController implements IMonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedQuery = getMonitorsWithChecksQueryValidation.parse(req.query);
const limit = validatedQuery.limit;
const page = validatedQuery.page;
const rowsPerPage = validatedQuery.rowsPerPage;
const filter = validatedQuery.filter;
const field = validatedQuery.field;
const order = validatedQuery.order;
const type = validatedQuery.type;
const teamId = requireTeamId(req.user?.teamId);
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsWithChecksQueryValidation.validateAsync(req.query);
const explain = optionalBoolean(req?.query?.explain, "explain");
const limit = optionalNumber(req?.query?.limit, "limit");
const page = optionalNumber(req?.query?.page, "page");
const rowsPerPage = optionalNumber(req?.query?.rowsPerPage, "rowsPerPage");
const filter = optionalString(req?.query?.filter, "filter");
const field = optionalString(req?.query?.field, "field");
const order = parseSortOrder(req?.query?.order);
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({
teamId,
@@ -365,6 +345,7 @@ class MonitorController implements IMonitorController {
filter,
field,
order,
explain,
});
return res.status(200).json({
@@ -378,7 +359,11 @@ class MonitorController implements IMonitorController {
exportMonitorsToJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const json = await this.monitorService.exportMonitorsToJSON({ teamId });
return res.status(200).json({
@@ -406,7 +391,11 @@ class MonitorController implements IMonitorController {
getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const groups = await this.monitorService.getGroupsByTeamId({ teamId });
return res.status(200).json({
@@ -417,42 +406,6 @@ class MonitorController implements IMonitorController {
next(error);
}
};
updateNotifications = async (req: Request, res: Response, next: NextFunction) => {
try {
updateNotificationsValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const { monitorIds, notificationIds, action } = req.body;
// Verify all requested notification IDs actually belong to this team
const teamNotifications = await this.notificationsService.findNotificationsByTeamId(teamId);
const validNotificationIds = teamNotifications.map((n) => n.id);
const invalidIds = notificationIds.filter((id: string) => !validNotificationIds.includes(id));
if (invalidIds.length > 0) {
throw new AppError({
message: `The following notification IDs are invalid or do not belong to your team: ${invalidIds.join(", ")}`,
status: 403,
});
}
const modifiedCount = await this.monitorService.updateNotifications({
teamId,
monitorIds,
notificationIds,
action,
});
return res.status(200).json({
success: true,
msg: `Notifications updated successfully on ${modifiedCount} monitor(s)`,
data: { modifiedCount },
});
} catch (error) {
next(error);
}
};
}
export default MonitorController;
@@ -1,30 +1,16 @@
import { Request, Response, NextFunction } from "express";
import {
createNotificationBodyValidation,
deleteNotificationParamValidation,
getNotificationByIdParamValidation,
testNotificationBodyValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
import { Notification } from "@/types/index.js";
import { createNotificationBodyValidation } from "@/validation/joi.js";
import { AppError } from "@/utils/AppError.js";
import { INotificationsService } from "@/service/index.js";
import { requireTeamId, requireUserId } from "./controllerUtils.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { INotificationsService } from "@/service/index.js";
import { requireTeamId } from "./controllerUtils.js";
const SERVICE_NAME = "NotificationController";
export interface INotificationController {
testNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
createNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getNotificationsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getNotificationById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
testAllNotifications: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class NotificationController implements INotificationController {
class NotificationController {
static SERVICE_NAME = SERVICE_NAME;
private notificationsService: INotificationsService;
private monitorsRepository: IMonitorsRepository;
constructor(notificationsService: INotificationsService, monitorsRepository: IMonitorsRepository) {
@@ -32,9 +18,13 @@ class NotificationController implements INotificationController {
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return NotificationController.SERVICE_NAME;
}
testNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const notification = testNotificationBodyValidation.parse(req.body);
const notification: Notification = req.body;
const success = await this.notificationsService.sendTestNotification(notification);
if (!success) {
@@ -44,7 +34,6 @@ class NotificationController implements INotificationController {
return res.status(200).json({
success: true,
msg: "Notification sent successfully",
details: { service: SERVICE_NAME },
});
} catch (error) {
next(error);
@@ -53,12 +42,25 @@ class NotificationController implements INotificationController {
createNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createNotificationBodyValidation.parse(req.body);
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const body = req.body;
const notification = await this.notificationsService.createNotification(validatedBody, userId, teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const userId = req?.user?.id;
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
body.userId = userId;
body.teamId = teamId;
const notification = await this.notificationsService.createNotification(body);
return res.status(200).json({
success: true,
msg: "Notification created successfully",
@@ -71,7 +73,11 @@ class NotificationController implements INotificationController {
getNotificationsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const notifications = await this.notificationsService.findNotificationsByTeamId(teamId);
return res.status(200).json({
@@ -86,10 +92,17 @@ class NotificationController implements INotificationController {
deleteNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedParams = deleteNotificationParamValidation.parse(req.params);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
await this.notificationsService.deleteById(validatedParams.id, teamId);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
await this.notificationsService.deleteById(notificationId, teamId);
return res.status(200).json({
success: true,
msg: "Notification deleted successfully",
@@ -102,9 +115,12 @@ class NotificationController implements INotificationController {
getNotificationById = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedParams = getNotificationByIdParamValidation.parse(req.params);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const notification = await this.notificationsService.findById(validatedParams.id, teamId);
const notification = await this.notificationsService.findById(notificationId, teamId);
return res.status(200).json({
success: true,
@@ -118,13 +134,16 @@ class NotificationController implements INotificationController {
editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createNotificationBodyValidation.parse(req.body);
const validatedParams = editNotificationParamValidation.parse(req.params);
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
const teamId = requireTeamId(req.user?.teamId);
const notificationId = validatedParams.id;
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, req.body);
return res.status(200).json({
success: true,
msg: "Notification updated successfully",
@@ -137,13 +156,15 @@ class NotificationController implements INotificationController {
testAllNotifications = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = testAllNotificationsBodyValidation.parse(req.body);
const monitorId = req.body.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorsRepository.findById(validatedBody.monitorId, teamId);
const notifications = monitor.notifications || [];
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
const notifications = monitor.notifications;
if (notifications.length === 0) {
throw new AppError({ message: "No notifications", status: 400 });
}
+28 -11
View File
@@ -1,19 +1,11 @@
import { ISuperSimpleQueue } from "@/service/index.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "JobQueueController";
export interface IJobQueueController {
getMetrics(req: Request, res: Response, next: NextFunction): Promise<void>;
getJobs(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getAllMetrics(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
flushQueue(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class JobQueueController implements IJobQueueController {
class JobQueueController {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
constructor(jobQueue: ISuperSimpleQueue) {
private jobQueue: any;
constructor(jobQueue: any) {
this.jobQueue = jobQueue;
}
@@ -61,6 +53,18 @@ class JobQueueController implements IJobQueueController {
}
};
addJob = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.jobQueue.addJob(Math.random().toString(36).substring(7));
return res.status(200).json({
success: true,
msg: "Job added to queue successfully",
});
} catch (error) {
next(error);
}
};
flushQueue = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.jobQueue.flushQueues();
@@ -73,5 +77,18 @@ class JobQueueController implements IJobQueueController {
next(error);
}
};
checkQueueHealth = async (req: Request, res: Response, next: NextFunction) => {
try {
const stuckQueues = await this.jobQueue.checkQueueHealth();
return res.status(200).json({
success: true,
msg: "Queue health checked successfully",
data: stuckQueues,
});
} catch (error) {
next(error);
}
};
}
export default JobQueueController;
+17 -34
View File
@@ -1,24 +1,14 @@
import { Request, Response, NextFunction } from "express";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { sendTestEmailBodyValidation, updateAppSettingsBodyValidation } from "@/validation/joi.js";
import { AppError } from "@/utils/AppError.js";
import { IEmailService, ISettingsService } from "@/service/index.js";
import { Settings } from "@/types/settings.js";
const SERVICE_NAME = "SettingsController";
export interface ISettingsController {
serviceName: string;
getAppSettings(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
updateAppSettings(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
sendTestEmail(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class SettingsController implements ISettingsController {
class SettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: ISettingsService;
private emailService: IEmailService;
constructor(settingsService: ISettingsService, emailService: IEmailService) {
private settingsService: any;
private emailService: any;
constructor(settingsService: any, emailService: any) {
this.settingsService = settingsService;
this.emailService = emailService;
}
@@ -27,11 +17,11 @@ class SettingsController implements ISettingsController {
return SettingsController.SERVICE_NAME;
}
buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
buildAppSettings = (dbSettings: any) => {
const sanitizedSettings = { ...dbSettings };
delete sanitizedSettings.version;
delete sanitizedSettings.jwtSecret;
const returnSettings: Record<string, unknown | null> = {
const returnSettings = {
pagespeedKeySet: false,
emailPasswordSet: false,
settings: null,
@@ -65,24 +55,20 @@ class SettingsController implements ISettingsController {
};
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = updateAppSettingsBodyValidation.parse(req.body);
await updateAppSettingsBodyValidation.validateAsync(req.body);
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
msg: "App settings updated successfully",
data: returnSettings,
});
} catch (error) {
next(error);
}
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
msg: "App settings updated successfully",
data: returnSettings,
});
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
sendTestEmailBodyValidation.parse(req.body);
await sendTestEmailBodyValidation.validateAsync(req.body);
const {
to,
@@ -104,9 +90,6 @@ class SettingsController implements ISettingsController {
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
if (!html) {
throw new AppError({ message: "Failed to build email template.", status: 500 });
}
const messageId = await this.emailService.sendEmail(to, subject, html, {
systemEmailHost,
systemEmailPort,
+10 -28
View File
@@ -1,30 +1,17 @@
import { Request, Response, NextFunction } from "express";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
getStatusPageQueryValidation,
imageValidation,
} from "@/validation/statusPageValidation.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "@/validation/joi.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ParseBoolean } from "@/utils/utils.js";
import { NormalizeData } from "@/utils/dataUtils.js";
const SERVICE_NAME = "statusPageController";
export interface IStatusPageController {
readonly serviceName: string;
createStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
updateStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class StatusPageController implements IStatusPageController {
class StatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private statusPageService: IStatusPageService;
private monitorsRepository: IMonitorsRepository;
@@ -41,10 +28,8 @@ class StatusPageController implements IStatusPageController {
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
createStatusPageBodyValidation.parse(req.body);
if (req.file) {
imageValidation.parse(req.file);
}
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
@@ -62,10 +47,8 @@ class StatusPageController implements IStatusPageController {
updateStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
createStatusPageBodyValidation.parse(req.body);
if (req.file) {
imageValidation.parse(req.file);
}
await createStatusPageBodyValidation.validateAsync(req.body);
await imageValidation.validateAsync(req.file);
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
@@ -75,7 +58,7 @@ class StatusPageController implements IStatusPageController {
if (statusPage === null) {
throw new AppError({ message: "Status page not found", status: 404 });
}
res.status(200).json({
return res.status(200).json({
success: true,
msg: "Status page updated successfully",
data: statusPage,
@@ -87,8 +70,8 @@ class StatusPageController implements IStatusPageController {
getStatusPageByUrl = async (req: Request, res: Response, next: NextFunction) => {
try {
getStatusPageParamValidation.parse(req.params);
getStatusPageQueryValidation.parse(req.query);
await getStatusPageParamValidation.validateAsync(req.params);
await getStatusPageQueryValidation.validateAsync(req.query);
if (!req.params.url) {
throw new AppError({ message: "Status page URL is required", status: 400 });
@@ -118,7 +101,6 @@ class StatusPageController implements IStatusPageController {
const normalizedMonitors = sortedMonitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, port, secret, notifications, ...rest } = monitor;
return { ...rest, checks: normalizedChecks };
}
-7
View File
@@ -1,7 +0,0 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+9 -16
View File
@@ -1,13 +1,10 @@
import mongoose from "mongoose";
import AppSettings from "@/db/models/AppSettings.js";
import { runMigrations } from "@/db/migration/index.js";
import AppSettings from "./models/AppSettings.js";
import { runMigrations } from "./migration/index.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
import { Pool } from "pg";
class MongoDB implements IDb {
class MongoDB {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -49,12 +46,12 @@ class MongoDB implements IDb {
});
await runMigrations();
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
throw error;
}
@@ -66,19 +63,15 @@ class MongoDB implements IDb {
await mongoose.disconnect();
this.logger.info({ message: "Disconnected from MongoDB", service: SERVICE_NAME, method: "disconnect" });
return;
} catch (error: unknown) {
} catch (error: any) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
-105
View File
@@ -1,105 +0,0 @@
import { IDb } from "@/db/IDb.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { Client, Pool } from "pg";
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
const SERVICE_NAME = "TimescaleDB";
class TimescaleDB implements IDb {
private pool: Pool | null = null;
constructor(
private logger: ILogger,
private envSettings: EnvConfig
) {}
private ensureDatabaseExists = async (connectionString: string) => {
const url = new URL(connectionString);
const dbName = url.pathname.slice(1); // Remove leading "/"
// Connect to the default "postgres" database
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (result.rowCount === 0) {
await client.query(`CREATE DATABASE "${dbName}"`);
this.logger.info({
message: `Created database "${dbName}"`,
service: SERVICE_NAME,
method: "ensureDatabaseExists",
});
}
await client.end();
};
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
await this.ensureDatabaseExists(connectionString);
this.pool = new Pool({ connectionString });
const client = await this.pool.connect();
client.release();
// Ensure TimescaleDB extension is enabled
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
this.logger.info({
message: "Connected to TimescaleDB",
service: SERVICE_NAME,
method: "connect",
});
await runTimescaleDBMigrations(this.pool, this.logger);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
disconnect = async () => {
try {
if (this.pool === null) {
return;
}
this.logger.info({
message: "Disconnecting from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
await this.pool.end();
this.pool = null;
this.logger.info({
message: "Disconnected from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
getPool = (): Pool => {
if (this.pool === null) {
throw new Error("TimescaleDB is not connected. Call connect() first.");
}
return this.pool;
};
}
export default TimescaleDB;
@@ -1,5 +1,4 @@
import mongoose from "mongoose";
import type { Document, AnyBulkWriteOperation } from "mongodb";
const CHECKS_COLLECTION = "checks";
const BACKUP_COLLECTION = "checks_backup";
@@ -86,7 +85,7 @@ const createTimeSeriesCollection = async (backedUp: boolean) => {
}
};
const validateDocument = (doc: Document): boolean => {
const validateDocument = (doc: any): boolean => {
if (!doc.createdAt) {
return false;
}
@@ -96,7 +95,7 @@ const validateDocument = (doc: Document): boolean => {
return true;
};
const storeFailedDocument = async (doc: Document, reason: string) => {
const storeFailedDocument = async (doc: any, reason: string) => {
const db = getDb();
await db.collection(FAILED_DOCS_COLLECTION).insertOne({
originalDoc: doc,
@@ -125,8 +124,8 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
stats.totalSource = await source.countDocuments();
const cursor = source.find().addCursorFlag("noCursorTimeout", true);
const operations: AnyBulkWriteOperation<Document>[] = [];
const invalidDocs: Document[] = [];
const operations: any[] = [];
const invalidDocs: any[] = [];
while (await cursor.hasNext()) {
const doc = await cursor.next();
@@ -140,7 +139,6 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
}
const { _id, monitorId, teamId, type, ...rest } = doc;
void _id;
const metadata = { monitorId, teamId, type };
operations.push({ insertOne: { document: { ...rest, metadata } } });
@@ -42,10 +42,10 @@ export async function cleanupDuplicateMonitorStats(): Promise<void> {
const monitorId = duplicate._id;
const { ids, updatedAts } = duplicate;
type DocPair = { id: string; updatedAt: Date };
type DocPair = { id: any; updatedAt: Date };
// Create array of {id, updatedAt} pairs and sort by updatedAt descending
const docs: DocPair[] = ids.map((id: string, index: number) => ({
const docs: DocPair[] = ids.map((id: any, index: number) => ({
id,
updatedAt: updatedAts[index],
}));
@@ -1,61 +0,0 @@
import { MonitorModel } from "../models/Monitor.js";
import { logger } from "@/utils/logger.js";
/**
* Fix infrastructure monitors that had thresholds incorrectly set to 0 or 5
* due to the old default value. Updates them to 100 (disabled).
*/
export async function fixInfrastructureThresholds(): Promise<void> {
const SERVICE_NAME = "Migration:FixInfrastructureThresholds";
try {
logger.info({ service: SERVICE_NAME, message: "Starting infrastructure threshold fix" });
const thresholdFields = ["cpuAlertThreshold", "memoryAlertThreshold", "diskAlertThreshold", "tempAlertThreshold"];
// Find hardware monitors with any threshold set to 0 or 5
const filter = {
type: "hardware",
$or: thresholdFields.flatMap((field) => [{ [field]: 0 }, { [field]: 5 }]),
};
const monitors = await MonitorModel.find(filter);
if (monitors.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No monitors with threshold value of 0 or 5 found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${monitors.length} monitors with threshold value of 0 or 5`,
});
let updatedCount = 0;
for (const monitor of monitors) {
const update: Record<string, number> = {};
for (const field of thresholdFields) {
const value = monitor.get(field);
if (value === 0 || value === 5) {
update[field] = 100;
}
}
if (Object.keys(update).length > 0) {
await MonitorModel.updateOne({ _id: monitor._id }, { $set: update });
updatedCount++;
}
}
logger.info({
service: SERVICE_NAME,
message: `Fixed ${updatedCount} monitors — thresholds updated from 0/5 to 100`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error fixing thresholds: ${errorMessage}` });
throw error;
}
}
@@ -1,45 +0,0 @@
import mongoose from "mongoose";
import { logger } from "@/utils/logger.js";
import StatusPageModel from "../models/StatusPage.js";
export async function migrateStatusPageTypeToArray(): Promise<void> {
const SERVICE_NAME = "Migration:MigrateStatusPageTypeToArray";
try {
logger.info({ service: SERVICE_NAME, message: "Starting migration of StatusPage type field from string to array" });
const db = mongoose.connection.db;
if (!db) {
throw new Error("Database connection is not initialized");
}
const result = await StatusPageModel.updateMany(
{
$expr: {
$eq: [{ $type: "$type" }, "string"], // only scalar string, not existing array
},
},
[
{
$set: {
type: ["$type"],
},
},
]
);
if (result.modifiedCount === 0) {
logger.info({ service: SERVICE_NAME, message: "No StatusPage documents needed migration" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Migration complete. Converted ${result.modifiedCount} StatusPage document(s) from string type to array type`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during StatusPage type migration: ${errorMessage}` });
throw error;
}
}
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more