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
156 changed files with 3753 additions and 7280 deletions

No files matched your search

+1 -3
View File
@@ -5,6 +5,4 @@
mongo
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
docs/reviews
+1 -1
View File
@@ -42,7 +42,7 @@ 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
+104 -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) {
@@ -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%",
}}
@@ -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"
+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
@@ -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 -19
View File
@@ -14,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,
});
@@ -73,16 +73,6 @@ 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,
@@ -103,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,
+4 -16
View File
@@ -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: "",
+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
+38 -174
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,15 +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,
} 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";
@@ -49,8 +42,6 @@ interface GeneralSettingsConfig {
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
}
const getGeneralSettingsConfig = (
@@ -66,8 +57,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -77,8 +66,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -88,8 +75,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -99,8 +84,6 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -110,19 +93,6 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: true,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: 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,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -132,8 +102,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -143,19 +111,6 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: true,
showGrpcServiceName: false,
showIgnoreTls: 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,
},
};
return configs[type] || configs.http;
@@ -271,7 +226,7 @@ const CreateMonitorPage = () => {
};
const onError = (errors: unknown) => {
logger.debug("Monitor creation validation errors", errors);
console.log(errors);
};
return (
@@ -298,7 +253,7 @@ const CreateMonitorPage = () => {
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
sx={{ gap: theme.spacing(6) }}
>
<RadioWithDescription
value="http"
@@ -335,20 +290,6 @@ const CreateMonitorPage = () => {
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
<RadioWithDescription
value="grpc"
label={t("pages.createMonitor.form.type.optionGrpc")}
description={t(
"pages.createMonitor.form.type.optionGrpcDescription"
)}
/>
<RadioWithDescription
value="websocket"
label={t("pages.createMonitor.form.type.optionWebSocket")}
description={t(
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
@@ -361,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
@@ -437,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
@@ -580,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}
@@ -662,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}
@@ -714,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}
@@ -765,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")}
@@ -779,7 +694,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -800,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}
@@ -808,7 +723,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -823,7 +738,7 @@ const CreateMonitorPage = () => {
)}
/>
{watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(8)}>
<Controller
name="matchMethod"
control={control}
@@ -910,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}
@@ -923,7 +839,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
spacing={theme.spacing(2)}
>
<Switch
checked={field.value ?? false}
@@ -936,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>
/>
);
}}
/>
@@ -1008,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}
+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;
+80 -89
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";
@@ -23,7 +14,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
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";
@@ -266,7 +257,8 @@ export const SettingsPage = () => {
(acc as any)[typedKey] = Object.entries(value).reduce(
(nested, [nestedKey, nestedValue]) => ({
...nested,
[nestedKey]: nestedValue === undefined ? "" : nestedValue,
[nestedKey]:
nestedValue === undefined || nestedValue === 0 ? "" : nestedValue,
}),
{}
);
@@ -290,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 || {});
@@ -300,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")}
@@ -323,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}
@@ -473,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}
/>
)}
/>
@@ -490,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}
/>
)}
/>
@@ -507,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}
/>
)}
/>
@@ -524,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}
/>
)}
/>
@@ -551,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")}
@@ -599,7 +623,7 @@ export const SettingsPage = () => {
</Stack>
}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Stack gap={theme.spacing(10)}>
{/* Email Host */}
<Controller
name="systemEmailHost"
@@ -686,7 +710,7 @@ export const SettingsPage = () => {
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(4)}
>
<Button
variant="contained"
@@ -761,7 +785,7 @@ export const SettingsPage = () => {
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(LAYOUT.XS),
gap: theme.spacing(4),
}}
>
{[
@@ -880,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
@@ -966,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"
+10 -12
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";
@@ -82,7 +80,7 @@ const CreateStatusPage = () => {
}, [defaults, reset]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
console.log(errors);
};
const handleDeleteClick = () => {
@@ -130,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);
}
}
}
@@ -167,7 +165,7 @@ const CreateStatusPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
spacing={theme.spacing(2)}
>
<Controller
name="isPublished"
@@ -189,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}
@@ -244,7 +242,7 @@ const CreateStatusPage = () => {
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(4)}>
<Autocomplete
multiple
options={monitors}
@@ -292,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}`,
@@ -368,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"
@@ -409,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}
@@ -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(() => {
+9
View File
@@ -160,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;
@@ -236,6 +244,7 @@ export interface UptimeChecksResult {
groupedDownChecks: GroupedCheck[];
uptimePercentage: number;
avgResponseTime: number;
groupedGeoChecks?: GroupedGeoCheck[];
}
export interface ChecksSummary {
-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;
}
+4 -15
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,6 +25,9 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export const GeoCheckLocations = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoCheckLocation = (typeof GeoCheckLocations)[number];
export interface Monitor {
id: string;
userId: string;
@@ -71,10 +61,9 @@ export interface Monitor {
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
grpcServiceName?: string;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckLocations?: GeoCheckLocation[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: 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 -29
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -22,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
@@ -73,18 +69,6 @@ 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"),
@@ -115,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,
@@ -129,10 +106,8 @@ export const monitorSchema = z.discriminatedUnion("type", [
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -144,8 +119,6 @@ export {
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
};
+28 -6
View File
@@ -40,12 +40,34 @@ export const settingsSchema = z.object({
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.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),
}),
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(),
});
export type SettingsFormData = z.infer<typeof settingsSchema>;
+30 -60
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"
}
}
},
@@ -434,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": {
@@ -484,14 +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"
}
},
"title": "General settings",
@@ -502,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."
}
},
@@ -538,10 +550,6 @@
"optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpc": "gRPC",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocket": "WebSocket",
"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",
@@ -568,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.",
@@ -627,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": {
@@ -673,6 +645,7 @@
},
"filters": {
"allMonitors": "All monitors",
"clearFilters": "Clear filters",
"monitor": "Monitor",
"resolutionType": "Resolution type",
"resolutionTypes": {
@@ -1148,9 +1121,6 @@
"about": {
"title": "About",
"developedBy": "Developed by Bluewave Labs"
},
"validation": {
"errorMessage": "Please fix the following validation errors:"
}
}
},
+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
-14
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,19 +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: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@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
},
},
+40
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",
+848 -1138
View File
File diff suppressed because it is too large. Load diff
+6 -13
View File
@@ -15,7 +15,7 @@
"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,28 +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",
"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",
@@ -79,9 +76,6 @@
"@types/papaparse": "^5.5.2",
"@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",
@@ -95,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);
}
+2 -16
View File
@@ -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({
-3
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;
@@ -34,7 +32,6 @@ export const initializeControllers = (services: InitializedServices): Initialize
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),
-3
View File
@@ -6,7 +6,6 @@ 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";
@@ -23,7 +22,6 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
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);
@@ -38,7 +36,6 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
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());
+67 -118
View File
@@ -1,69 +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,
// Interfaces
INetworkService,
IEmailService,
IBufferService,
ISuperSimpleQueue,
INotificationsService,
IStatusService,
IMonitorService,
IUserService,
ICheckService,
IGeoChecksService,
IDiagnosticService,
IInviteService,
IMaintenanceWindowService,
IStatusPageService,
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
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";
@@ -76,15 +41,11 @@ 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,
@@ -96,7 +57,6 @@ import {
MongoMaintenanceWindowsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -109,24 +69,24 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.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;
@@ -134,7 +94,6 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -155,7 +114,7 @@ export const initializeServices = async ({
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
settingsService: any;
settingsRepository: ISettingsRepository;
}): Promise<InitializedServices> => {
// Create DB
@@ -167,7 +126,6 @@ export const initializeServices = async ({
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
@@ -178,50 +136,41 @@ export const initializeServices = async ({
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// 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);
@@ -243,13 +192,13 @@ export const initializeServices = async ({
notificationMessageBuilder
);
const superSimpleQueueHelper = new SuperSimpleQueueHelper(
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
logger,
networkService,
statusService,
notificationsService,
checkService,
bufferService,
buffer: bufferService,
incidentService,
maintenanceWindowsRepository,
monitorsRepository,
@@ -257,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({
@@ -296,7 +247,6 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -305,6 +255,7 @@ export const initializeServices = async ({
const statusPageService = new StatusPageService(statusPagesRepository);
const services = {
//v1
settingsService,
db,
networkService,
@@ -314,7 +265,6 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -328,7 +278,6 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
+16 -33
View File
@@ -6,20 +6,17 @@ 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";
} from "@/validation/joi.js";
const SERVICE_NAME = "authController";
@@ -43,7 +40,7 @@ class AuthController {
if (newUser?.email) {
newUser.email = newUser.email.toLowerCase();
}
registrationBodyValidation.parse(newUser);
await registrationBodyValidation.validateAsync(newUser);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
res.status(200).json({
@@ -62,7 +59,7 @@ class AuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
createUserBodyValidation.parse(userData);
await createUserBodyValidation.validateAsync(userData);
const teamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
@@ -82,7 +79,7 @@ class AuthController {
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({
@@ -100,7 +97,7 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
editUserBodyValidation.parse(req.body);
await editUserBodyValidation.validateAsync(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
@@ -128,7 +125,7 @@ class AuthController {
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({
@@ -143,7 +140,7 @@ class AuthController {
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,
@@ -156,7 +153,7 @@ class AuthController {
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,
@@ -180,20 +177,6 @@ class AuthController {
}
};
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
getUserByIdParamValidation.parse(req.params);
const targetUserId = req.params.userId;
await this.userService.deleteUserById(req.user, 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();
@@ -209,7 +192,7 @@ class AuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
getUserByIdParamValidation.parse(req.params);
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
@@ -240,12 +223,12 @@ class AuthController {
const userId = req.params.userId as string;
const user = { ...req.body };
editUserByIdParamValidation.parse(req.params);
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
editSuperadminUserByIdBodyValidation.parse(req.body);
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
editUserByIdBodyValidation.parse(req.body);
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.userService.editUserById(userId, user);
@@ -263,8 +246,8 @@ class AuthController {
}
const userId = req.params.userId as string;
editUserByIdParamValidation.parse(req.params);
editUserPasswordByIdBodyValidation.parse(req.body);
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" });
+10 -7
View File
@@ -7,7 +7,10 @@ import {
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
} from "@/validation/checkValidation.js";
ackCheckBodyValidation,
ackAllChecksParamValidation,
ackAllChecksBodyValidation,
} from "@/validation/joi.js";
const SERVICE_NAME = "checkController";
@@ -25,8 +28,8 @@ class CheckController {
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getChecksParamValidation.parse(req.params);
getChecksQueryValidation.parse(req.query);
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
const result = await this.checkService.getChecksByMonitor({
monitorId: req?.params?.monitorId,
@@ -46,7 +49,7 @@ class CheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
getTeamChecksQueryValidation.parse(req.query);
await getTeamChecksQueryValidation.validateAsync(req.query);
const checkData = await this.checkService.getChecksByTeam({
teamId: req?.user?.teamId,
query: req?.query,
@@ -77,7 +80,7 @@ class CheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteChecksParamValidation.parse(req.params);
await deleteChecksParamValidation.validateAsync(req.params);
const deletedCount = await this.checkService.deleteChecks({
monitorId: req.params.monitorId as string,
@@ -96,7 +99,7 @@ class CheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteChecksByTeamIdParamValidation.parse(req.params);
await deleteChecksByTeamIdParamValidation.validateAsync(req.params);
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
@@ -112,7 +115,7 @@ class CheckController {
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
updateChecksTTLBodyValidation.parse(req.body);
await updateChecksTTLBodyValidation.validateAsync(req.body);
await this.checkService.updateChecksTTL({
teamId: req?.user?.teamId,
@@ -1,41 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
const SERVICE_NAME = "geoCheckController";
class GeoCheckController {
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 {
getChecksParamValidation.parse(req.params);
getChecksQueryValidation.parse(req.query);
const result = await this.geoChecksService.getGeoChecksByMonitor({
monitorId: req?.params?.monitorId as string,
query: req?.query,
teamId: req?.user?.teamId as string,
});
return res.status(200).json({
success: true,
msg: "Geo checks retrieved successfully",
data: result,
});
} catch (error) {
next(error);
}
};
}
export default GeoCheckController;
+4 -4
View File
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "inviteController";
@@ -20,7 +20,7 @@ class InviteController {
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,
@@ -39,7 +39,7 @@ class InviteController {
const inviteRequest = req.body;
inviteRequest.teamId = teamId;
inviteBodyValidation.parse(inviteRequest);
await inviteBodyValidation.validateAsync(inviteRequest);
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
@@ -58,7 +58,7 @@ class InviteController {
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,
@@ -7,7 +7,7 @@ import {
getMaintenanceWindowsByMonitorIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
} from "@/validation/maintenanceWindowValidation.js";
} from "@/validation/joi.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "maintenanceWindowController";
@@ -25,7 +25,7 @@ class MaintenanceWindowController {
createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => {
try {
createMaintenanceWindowBodyValidation.parse(req.body);
await createMaintenanceWindowBodyValidation.validateAsync(req.body);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body });
@@ -40,7 +40,7 @@ class MaintenanceWindowController {
};
getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowByIdParamValidation.parse(req.params);
await getMaintenanceWindowByIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req.user?.teamId);
@@ -58,7 +58,7 @@ class MaintenanceWindowController {
getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req?.user?.teamId);
@@ -76,7 +76,7 @@ class MaintenanceWindowController {
getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req?.user?.teamId);
@@ -96,7 +96,7 @@ class MaintenanceWindowController {
};
deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteMaintenanceWindowByIdParamValidation.parse(req.params);
await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params);
const teamId = requireTeamId(req?.user?.teamId);
@@ -113,8 +113,8 @@ class MaintenanceWindowController {
editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
editMaintenanceWindowByIdParamValidation.parse(req.params);
editMaintenanceByIdWindowBodyValidation.parse(req.body);
await editMaintenanceWindowByIdParamValidation.validateAsync(req.params);
await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body);
const teamId = requireTeamId(req.user?.teamId);
+17 -50
View File
@@ -12,7 +12,7 @@ import {
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
} from "@/validation/monitorValidation.js";
} from "@/validation/joi.js";
import sslChecker from "ssl-checker";
import {
fetchMonitorCertificate,
@@ -27,7 +27,6 @@ import {
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
@@ -45,7 +44,7 @@ class MonitorController {
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
getCertificateParamValidation.parse(req.params);
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 });
@@ -88,8 +87,8 @@ class MonitorController {
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
@@ -112,8 +111,8 @@ class MonitorController {
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
@@ -135,42 +134,10 @@ class MonitorController {
}
};
getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const continentParam = req?.query?.continent;
const continents = continentParam
? Array.isArray(continentParam)
? (continentParam as GeoContinent[])
: [continentParam as GeoContinent]
: 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 {
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
@@ -189,7 +156,7 @@ class MonitorController {
createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
createMonitorBodyValidation.parse(req.body);
await createMonitorBodyValidation.validateAsync(req.body);
const userId = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -231,7 +198,7 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
await getMonitorByIdParamValidation.validateAsync(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -264,8 +231,8 @@ class MonitorController {
editMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
editMonitorBodyValidation.parse(req.body);
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -283,7 +250,7 @@ class MonitorController {
pauseMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
pauseMonitorParamValidation.parse(req.params);
await pauseMonitorParamValidation.validateAsync(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
@@ -336,8 +303,8 @@ class MonitorController {
getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsByTeamIdQueryValidation.parse(req.query);
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
@@ -357,8 +324,8 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsWithChecksQueryValidation.parse(req.query);
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");
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { Notification } from "@/types/index.js";
import { createNotificationBodyValidation } from "@/validation/notificationValidation.js";
import { createNotificationBodyValidation } from "@/validation/joi.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { INotificationsService } from "@/service/index.js";
@@ -42,7 +42,9 @@ class NotificationController {
createNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
createNotificationBodyValidation.parse(req.body);
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
const body = req.body;
@@ -132,7 +134,9 @@ class NotificationController {
editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
createNotificationBodyValidation.parse(req.body);
await createNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
});
const teamId = requireTeamId(req.user?.teamId);
const notificationId = req.params.id as string;
+10 -15
View File
@@ -1,6 +1,5 @@
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";
const SERVICE_NAME = "SettingsController";
@@ -56,24 +55,20 @@ class SettingsController {
};
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
try {
updateAppSettingsBodyValidation.parse(req.body);
await updateAppSettingsBodyValidation.validateAsync(req.body);
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,
});
} 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,
+8 -17
View File
@@ -1,16 +1,12 @@
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";
@@ -32,10 +28,8 @@ class StatusPageController {
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);
@@ -53,10 +47,8 @@ class StatusPageController {
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) {
@@ -78,8 +70,8 @@ class StatusPageController {
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 });
@@ -109,7 +101,6 @@ class StatusPageController {
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 };
}
-4
View File
@@ -1,4 +0,0 @@
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
}
+9 -11
View File
@@ -1,12 +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";
class MongoDB implements IDb {
class MongoDB {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
@@ -48,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;
}
@@ -65,12 +63,12 @@ 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,
});
}
};
@@ -1,60 +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) {
if ((monitor as any)[field] === 0 || (monitor as any)[field] === 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;
}
}
-2
View File
@@ -1,7 +1,6 @@
import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js";
import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
type MigrationEntry = {
@@ -13,7 +12,6 @@ const migrations: MigrationEntry[] = [
{ name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold },
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
+34
View File
@@ -11,6 +11,8 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
GeoCheckResult,
GeoCheckTimings,
GotTimings,
ILighthouseAudit,
} from "@/types/check.js";
@@ -175,6 +177,34 @@ const auditsSchema = new Schema<CheckAudits>(
{ _id: false }
);
const geoCheckTimingsSchema = new Schema<GeoCheckTimings>(
{
total: { type: Number, required: true },
dns: { type: Number },
tcp: { type: Number },
tls: { type: Number },
firstByte: { type: Number },
download: { type: Number },
},
{ _id: false }
);
const geoCheckResultSchema = new Schema<GeoCheckResult>(
{
continent: { type: String, required: true },
region: { type: String },
country: { type: String, required: true },
state: { type: String },
city: { type: String, required: true },
longitude: { type: Number, required: true },
latitude: { type: Number, required: true },
status: { type: Boolean, required: true },
statusCode: { type: Number, required: true },
timings: { type: geoCheckTimingsSchema, required: true },
},
{ _id: false }
);
const metadataSchema = new Schema<CheckMetadataDocument>(
{
monitorId: {
@@ -281,6 +311,10 @@ const CheckSchema = new Schema<CheckDocument>(
type: auditsSchema,
default: undefined,
},
geoResults: {
type: [geoCheckResultSchema],
default: undefined,
},
},
{
timestamps: true,
-116
View File
@@ -1,116 +0,0 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/types/monitor.js";
import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/types/geoCheck.js";
type GeoCheckMetadataDocument = Omit<GeoCheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
teamId: Types.ObjectId;
type: MonitorType;
};
type GeoCheckDocumentBase = Omit<GeoCheck, "id" | "metadata" | "expiry" | "createdAt" | "updatedAt" | "results"> & {
metadata: GeoCheckMetadataDocument;
results: GeoCheckResult[];
expiry: Date;
createdAt: Date;
updatedAt: Date;
__v: number;
};
export interface GeoCheckDocument extends GeoCheckDocumentBase {
_id: Types.ObjectId;
}
const geoCheckMetadataSchema = new Schema<GeoCheckMetadataDocument>(
{
monitorId: { type: Schema.Types.ObjectId, required: true, index: true },
teamId: { type: Schema.Types.ObjectId, required: true, index: true },
type: { type: String, required: true, enum: MonitorTypes },
},
{ _id: false }
);
const geoCheckTimingsSchema = new Schema<GeoCheckTimings>(
{
total: { type: Number, default: 0 },
dns: { type: Number, default: 0 },
tcp: { type: Number, default: 0 },
tls: { type: Number, default: 0 },
firstByte: { type: Number, default: 0 },
download: { type: Number, default: 0 },
},
{ _id: false }
);
const geoCheckLocationSchema = new Schema<GeoCheckLocation>(
{
continent: { type: String, required: true },
region: { type: String, default: "" },
country: { type: String, default: "" },
state: { type: String, default: "" },
city: { type: String, default: "" },
longitude: { type: Number, default: 0 },
latitude: { type: Number, default: 0 },
},
{ _id: false }
);
const geoCheckResultSchema = new Schema<GeoCheckResult>(
{
location: {
type: geoCheckLocationSchema,
required: true,
},
status: {
type: Boolean,
required: true,
},
statusCode: {
type: Number,
required: true,
},
timings: {
type: geoCheckTimingsSchema,
required: true,
},
},
{ _id: false }
);
const GeoCheckSchema = new Schema<GeoCheckDocument>(
{
metadata: {
type: geoCheckMetadataSchema,
required: true,
},
results: {
type: [geoCheckResultSchema],
required: true,
default: [],
},
expiry: {
type: Date,
default: Date.now,
},
},
{
timestamps: true,
strict: false,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
granularity: "seconds",
},
}
);
GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 });
GeoCheckSchema.index({ "metadata.monitorId": 1, createdAt: 1 });
GeoCheckSchema.index({ "metadata.teamId": 1, createdAt: -1 });
GeoCheckSchema.index({ createdAt: 1 });
const GeoCheckModel = model<GeoCheckDocument>("GeoCheck", GeoCheckSchema);
export type { GeoCheckMetadataDocument };
export { GeoCheckModel };
export default GeoCheckModel;
+3 -6
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types, type UpdateQuery } from "mongoose";
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses, GeoCheckLocations } from "@/types/monitor.js";
type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };
@@ -160,10 +160,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
gameId: {
type: String,
},
grpcServiceName: {
type: String,
default: "",
},
group: {
type: String,
trim: true,
@@ -179,6 +175,7 @@ const MonitorSchema = new Schema<MonitorDocument>(
},
geoCheckLocations: {
type: [String],
enum: GeoCheckLocations,
default: [],
},
geoCheckInterval: {
-3
View File
@@ -33,6 +33,3 @@ export { default as TeamModel } from "@/db/models/Team.js";
export * from "@/db/models/MaintenanceWindow.js";
export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow.js";
export * from "@/db/models/GeoCheck.js";
export { default as GeoCheckModel } from "@/db/models/GeoCheck.js";
+5 -8
View File
@@ -2,23 +2,19 @@ import { initializeServices } from "./config/services.js";
import { initializeControllers } from "./config/controllers.js";
import { createApp } from "./app.js";
import { initShutdownListener } from "./shutdown.js";
import { validateEnv } from "./validation/envValidation.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";
import { runMigrations } from "./db/migration/index.js";
import Logger, { ILogger } from "@/utils/logger.js";
import { SettingsService } from "@/service/index.js";
import SettingsService from "@/service/system/settingsService.js";
import { MongoSettingsRepository } from "./repositories/index.js";
const SERVICE_NAME = "Server";
let logger: ILogger;
const startApp = async () => {
// Validate environment variables first
const env = validateEnv();
// FE path
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -27,7 +23,7 @@ const startApp = async () => {
// Create services
const settingsRepository = new MongoSettingsRepository();
const settingsService = new SettingsService(settingsRepository, env);
const settingsService = new SettingsService(settingsRepository);
const envSettings = settingsService.loadSettings();
@@ -50,8 +46,9 @@ const startApp = async () => {
openApiSpec,
});
const server = app.listen(env.PORT, () => {
logger.info({ message: `Server started on port:${env.PORT}` });
const port = envSettings.port || 52345;
const server = app.listen(port, () => {
logger.info({ message: `Server started on port:${port}` });
});
initShutdownListener(server, services);
@@ -20,19 +20,12 @@ export interface IChecksRepository {
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
filter: string,
page: number,
rowsPerPage: number,
status: boolean | undefined
): Promise<ChecksQueryResult>;
findByTeamId(
sortOrder: string,
dateRange: string,
filter: string | undefined,
page: number,
rowsPerPage: number,
teamId: string
): Promise<ChecksQueryResult>;
findByTeamId(sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string): Promise<ChecksQueryResult>;
findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findByDateRangeAndMonitorId(
monitorId: string,
@@ -10,15 +10,25 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
GeoCheckResult,
GeoCheckTimings,
GotTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
const SERVICE_NAME = "StatusService";
const dateRangeLookup: Record<string, Date | undefined> = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
hour: new Date(new Date().setHours(new Date().getHours() - 1)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: undefined,
};
export type LatestChecksMap = Record<string, Check[]>;
type DateRange = { start: Date; end: Date };
type HardwareUpChecks = { totalChecks: number };
@@ -157,6 +167,33 @@ class MongoChecksRepository implements IChecksRepository {
};
};
const mapGeoTimings = (timings?: GeoCheckTimings): GeoCheckTimings => ({
total: timings?.total ?? 0,
dns: timings?.dns,
tcp: timings?.tcp,
tls: timings?.tls,
firstByte: timings?.firstByte,
download: timings?.download,
});
const mapGeoResults = (geoResults?: GeoCheckResult[]): GeoCheckResult[] | undefined => {
if (!geoResults?.length) {
return undefined;
}
return geoResults.map((result) => ({
continent: result.continent ?? "",
region: result.region,
country: result.country ?? "",
state: result.state,
city: result.city ?? "",
longitude: result.longitude ?? 0,
latitude: result.latitude ?? 0,
status: result.status ?? false,
statusCode: result.statusCode ?? 0,
timings: mapGeoTimings(result.timings),
}));
};
const mapMetadata = (metadata: CheckDocument["metadata"]): CheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId),
@@ -186,6 +223,7 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
geoResults: mapGeoResults(doc.geoResults),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
@@ -231,7 +269,7 @@ class MongoChecksRepository implements IChecksRepository {
monitorId: string,
sortOrder: string,
dateRange: string,
filter: string | undefined,
filter: string,
page: number,
rowsPerPage: number,
status: boolean | undefined
@@ -240,9 +278,9 @@ class MongoChecksRepository implements IChecksRepository {
const matchStage: Record<string, any> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
...(getDateForRange(dateRange) && {
...(dateRangeLookup[dateRange] && {
createdAt: {
$gte: getDateForRange(dateRange),
$gte: dateRangeLookup[dateRange],
},
}),
};
@@ -291,9 +329,9 @@ class MongoChecksRepository implements IChecksRepository {
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record<string, any> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
...(dateRangeLookup[dateRange] && {
createdAt: {
$gte: getDateForRange(dateRange),
$gte: dateRangeLookup[dateRange],
},
}),
};
@@ -378,9 +416,9 @@ class MongoChecksRepository implements IChecksRepository {
findSummaryByTeamId = async (teamId: string, dateRange: string) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
...(dateRangeLookup[dateRange] && {
createdAt: {
$gte: getDateForRange(dateRange),
$gte: dateRangeLookup[dateRange],
},
}),
};
@@ -419,85 +457,131 @@ class MongoChecksRepository implements IChecksRepository {
endDate: Date,
dateString: string
) => {
const matchStage = {
// Match stage for primary checks (exclude geo checks)
const primaryCheckMatchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
geoResults: { $exists: false },
};
const [result] = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { createdAt: 1 } },
{
$facet: {
uptimePercentage: [
{
$group: {
_id: null,
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
totalChecks: { $sum: 1 },
},
},
{
$project: {
_id: 0,
percentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
// Match stage for geo checks only
const geoCheckMatchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
geoResults: { $exists: true, $ne: null },
};
// Run two separate aggregations - one for primary checks, one for geo checks
// TODO, only do this if the monitor has geochecks enabled, otherwise we only need one aggregation
const [primaryResult, geoResults] = await Promise.all([
CheckModel.aggregate([
{ $match: primaryCheckMatchStage },
{ $sort: { createdAt: 1 } },
{
$facet: {
uptimePercentage: [
{
$group: {
_id: null,
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
totalChecks: { $sum: 1 },
},
},
},
],
groupedAvgResponseTime: [
{
$group: {
_id: null,
avgResponseTime: { $avg: "$responseTime" },
},
},
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
{
$project: {
_id: 0,
percentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
groupedUpChecks: [
{ $match: { status: true } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
],
groupedAvgResponseTime: [
{
$group: {
_id: null,
avgResponseTime: { $avg: "$responseTime" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
groupedDownChecks: [
{ $match: { status: false } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
groupedUpChecks: [
{ $match: { status: true } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
groupedDownChecks: [
{ $match: { status: false } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
{ $project: { bucketDate: "$_id", avgResponseTime: 1, totalChecks: 1, _id: 0 } },
],
},
},
},
]),
CheckModel.aggregate([
{ $match: geoCheckMatchStage },
{ $sort: { createdAt: 1 } },
{ $unwind: "$geoResults" },
{
$group: {
_id: {
date: { $dateToString: { format: dateString, date: "$createdAt" } },
continent: "$geoResults.continent",
},
avgResponseTime: { $avg: "$geoResults.timings.total" },
totalChecks: { $sum: 1 },
upChecks: { $sum: { $cond: [{ $eq: ["$geoResults.status", true] }, 1, 0] } },
},
},
{
$project: {
_id: 0,
bucketDate: "$_id.date",
continent: "$_id.continent",
avgResponseTime: 1,
totalChecks: 1,
uptimePercentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
},
{ $sort: { bucketDate: 1, continent: 1 } },
]),
]);
const [result] = primaryResult;
const groupedGeoChecks = geoResults ?? [];
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
@@ -508,6 +592,7 @@ class MongoChecksRepository implements IChecksRepository {
groupedDownChecks: result?.groupedDownChecks ?? [],
uptimePercentage,
avgResponseTime,
groupedGeoChecks,
};
};
@@ -1,35 +0,0 @@
import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js";
import type { GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
export interface GeoChecksQueryResult {
geoChecksCount: number;
geoChecks: GeoCheck[];
}
export interface FlatGeoChecksQueryResult {
geoChecksCount: number;
geoChecks: FlatGeoCheck[];
}
export interface IGeoChecksRepository {
createGeoChecks(geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]>;
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]>;
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
}
@@ -1,387 +0,0 @@
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "GeoChecksRepository";
class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
private toEntity = (doc: GeoCheckDocument): GeoCheck => {
const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
const toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const mapMetadata = (metadata: any): GeoCheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId),
type: metadata.type,
});
const mapResults = (results: any[]): GeoCheckResult[] => {
if (!results || !Array.isArray(results)) {
return [];
}
return results.map((result) => ({
location: {
continent: result.location?.continent ?? "",
region: result.location?.region ?? "",
country: result.location?.country ?? "",
state: result.location?.state ?? "",
city: result.location?.city ?? "",
longitude: result.location?.longitude ?? 0,
latitude: result.location?.latitude ?? 0,
},
status: result.status ?? false,
statusCode: result.statusCode ?? 0,
timings: {
total: result.timings?.total ?? 0,
dns: result.timings?.dns ?? 0,
tcp: result.timings?.tcp ?? 0,
tls: result.timings?.tls ?? 0,
firstByte: result.timings?.firstByte ?? 0,
download: result.timings?.download ?? 0,
},
}));
};
return {
id: toStringId(doc._id),
metadata: mapMetadata(doc.metadata),
results: mapResults(doc.results),
expiry: toDateString(doc.expiry),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.insertMany(
geoChecks.map((geoCheck) => ({
metadata: {
monitorId: new mongoose.Types.ObjectId(geoCheck.metadata.monitorId),
teamId: new mongoose.Types.ObjectId(geoCheck.metadata.teamId),
type: geoCheck.metadata.type,
},
results: geoCheck.results,
expiry: new Date(geoCheck.expiry),
}))
);
return docs.map((doc) => this.toEntity(doc));
} catch (error: any) {
this.logger.error({
message: `Failed to createGeoChecks: ${error.message}`,
service: SERVICE_NAME,
method: "createGeoChecks",
stack: error.stack,
});
throw error;
}
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
try {
const matchStage: Record<string, any> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(getDateForRange(dateRange) && {
createdAt: {
$gte: getDateForRange(dateRange),
},
}),
};
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
if (continents && continents.length > 0) {
const pipeline: any[] = [
{ $match: matchStage },
{ $unwind: "$results" },
{
$match: {
"results.location.continent": { $in: continents },
},
},
];
} else {
const pipeline: any[] = [{ $match: matchStage }, { $unwind: "$results" }];
}
// Common pipeline stages for both paths
const pipeline: any[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Project to flat structure
{
$project: {
_id: 0,
monitorId: "$metadata.monitorId",
teamId: "$metadata.teamId",
type: "$metadata.type",
location: "$results.location",
status: "$results.status",
statusCode: "$results.statusCode",
timings: "$results.timings",
createdAt: 1,
updatedAt: 1,
},
},
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
// Count pipeline
const countPipeline: any[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
{ $count: "count" },
];
const [countResult, dataResults] = await Promise.all([GeoCheckModel.aggregate(countPipeline), GeoCheckModel.aggregate(pipeline)]);
const geoChecksCount = countResult[0]?.count || 0;
const geoChecks: FlatGeoCheck[] = dataResults.map((doc) => ({
id: `${doc.monitorId.toString()}-${new Date(doc.createdAt).getTime()}-${doc.location.continent}-${doc.location.city}-${Math.random().toString(36).substring(2, 15)}`,
monitorId: doc.monitorId.toString(),
teamId: doc.teamId.toString(),
type: doc.type,
location: doc.location,
status: doc.status,
statusCode: doc.statusCode,
timings: doc.timings,
createdAt: new Date(doc.createdAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
}));
return { geoChecksCount, geoChecks };
} catch (error: any) {
this.logger.error({
message: `Error finding geo checks by monitor ID: ${error.message}`,
service: SERVICE_NAME,
method: "findByMonitorId",
stack: error.stack,
});
throw error;
}
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
}).sort({ createdAt: -1 });
return docs.map(this.toEntity);
} catch (error: any) {
this.logger.error({
message: `Error finding geo checks by monitor ID and date range: ${error.message}`,
service: SERVICE_NAME,
method: "findByMonitorIdAndDateRange",
});
throw error;
}
};
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
try {
const pipeline: any[] = [
// Match geo checks for this monitor in date range
{
$match: {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: {
$gte: startDate,
$lte: endDate,
},
},
},
// Unwind the results array to process each location separately
{
$unwind: "$results",
},
// Filter by continent if specified
...(continents && continents.length > 0
? [
{
$match: {
"results.location.continent": { $in: continents },
},
},
]
: []),
// Group by date bucket and continent
{
$group: {
_id: {
bucketDate: {
$dateToString: {
format: dateFormat,
date: "$createdAt",
},
},
continent: "$results.location.continent",
},
avgResponseTime: { $avg: "$results.timings.total" },
totalChecks: { $sum: 1 },
upChecks: {
$sum: {
$cond: ["$results.status", 1, 0],
},
},
},
},
// Calculate uptime percentage
{
$project: {
_id: 0,
bucketDate: "$_id.bucketDate",
continent: "$_id.continent",
avgResponseTime: { $round: ["$avgResponseTime", 2] },
totalChecks: 1,
uptimePercentage: {
$round: [
{
$multiply: [
{
$divide: ["$upChecks", "$totalChecks"],
},
100,
],
},
2,
],
},
},
},
// Sort by date and continent
{
$sort: {
bucketDate: 1,
continent: 1,
},
},
];
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
} catch (error: any) {
this.logger.error({
message: `Error finding grouped geo checks: ${error.message}`,
service: SERVICE_NAME,
method: "findGroupedByMonitorIdAndDateRange",
});
throw error;
}
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
} catch (error: any) {
this.logger.error({
message: `Error deleting geo checks by monitor ID: ${error.message}`,
service: SERVICE_NAME,
method: "deleteByMonitorId",
});
throw error;
}
};
deleteByTeamId = async (teamId: string): Promise<number> => {
try {
const result = await GeoCheckModel.deleteMany({
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
} catch (error: any) {
this.logger.error({
message: `Error deleting geo checks by team ID: ${error.message}`,
service: SERVICE_NAME,
method: "deleteByTeamId",
});
throw error;
}
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
try {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
} catch (error: any) {
this.logger.error({
message: `Error deleting orphaned geo checks: ${error.message}`,
service: SERVICE_NAME,
method: "deleteByMonitorIdsNotIn",
});
throw error;
}
};
}
export { MongoGeoChecksRepository };
export default MongoGeoChecksRepository;
-3
View File
@@ -33,6 +33,3 @@ export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeams
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
@@ -36,7 +36,7 @@ class MongoInvitesRepository implements IInvitesRepository {
create = async (invite: Partial<Invite>) => {
await InviteModel.deleteMany({ email: invite.email });
invite.token = crypto.randomBytes(32).toString("hex");
const inviteToken = await InviteModel.create(invite);
let inviteToken = await InviteModel.create(invite);
return this.toEntity(inviteToken);
};
@@ -92,7 +92,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
// Sorting
const sort: Record<string, any> = {};
let sort: Record<string, any> = {};
if (field !== undefined && order !== undefined) {
sort[field] = order === "asc" ? 1 : -1;
}
@@ -352,12 +352,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
geoCheckInterval: doc.geoCheckInterval ?? 5,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -411,12 +410,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
tempAlertCounter: doc.tempAlertCounter,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
geoCheckInterval: doc.geoCheckInterval ?? 5,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -3,6 +3,7 @@ import { NotificationModel, type NotificationDocument } from "@/db/models/index.
import { INotificationsRepository } from "@/repositories/index.js";
import type { Notification } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { not } from "joi";
class MongoNotificationsRepository implements INotificationsRepository {
private mapDocuments = (documents: NotificationDocument[]): Notification[] => {
@@ -52,7 +52,7 @@ class MongoSettingsRepository implements ISettingsRepository {
};
findSingleton = async () => {
const settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
let settings = await AppSettingsModel.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean();
if (!settings) {
return null;
}
+1 -2
View File
@@ -26,10 +26,9 @@ class AuthRoutes {
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
-22
View File
@@ -1,22 +0,0 @@
import { Router } from "express";
class GeoCheckRoutes {
private router: Router;
private geoCheckController: any;
constructor(geoCheckController: any) {
this.router = Router();
this.geoCheckController = geoCheckController;
this.initRoutes();
}
initRoutes() {
this.router.get("/:monitorId", this.geoCheckController.getGeoChecksByMonitor);
}
getRouter() {
return this.router;
}
}
export default GeoCheckRoutes;
-3
View File
@@ -30,9 +30,6 @@ class MonitorRoutes {
// PageSpeed routes
this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById);
// Geo checks routes
this.router.get("/:monitorId/geo-checks", this.monitorController.getGeoChecksByMonitorId);
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
+28 -39
View File
@@ -1,31 +1,28 @@
import { requireTeamId } from "@/controllers/controllerUtils.js";
import { Types } from "mongoose";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } from "@/types/index.js";
import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>): Partial<Check> | undefined;
getChecksByMonitor(params: { monitorId: string; query: Record<string, string>; teamId: string }): Promise<ChecksQueryResult>;
getChecksByTeam(params: { teamId: string; query: Record<string, string> }): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
updateChecksTTL(params: { teamId: string; ttl: string }): Promise<void>;
}
export class CheckService implements ICheckService {
class CheckService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private logger: ILogger;
constructor(monitorsRepository: IMonitorsRepository, logger: ILogger, checksRepository: IChecksRepository) {
private logger: any;
constructor({
monitorsRepository,
logger,
checksRepository,
}: {
monitorsRepository: IMonitorsRepository;
logger: any;
checksRepository: IChecksRepository;
}) {
this.monitorsRepository = monitorsRepository;
this.logger = logger;
this.checksRepository = checksRepository;
@@ -64,12 +61,13 @@ export class CheckService implements ICheckService {
message: "Failed to build check",
service: SERVICE_NAME,
method: "buildCheck",
details: "empty payload",
});
return undefined;
}
const categories = pageSpeedPayload.lighthouseResult?.categories ?? {};
const audits = pageSpeedPayload.lighthouseResult?.audits ?? {};
const mapAudit = (audit: ILighthouseAudit | undefined) => {
const mapAudit = (audit: any) => {
if (!audit || typeof audit !== "object") {
return undefined;
}
@@ -112,7 +110,7 @@ export class CheckService implements ICheckService {
return check;
};
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: Record<string, string>; teamId: string }) => {
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
@@ -120,39 +118,28 @@ export class CheckService implements ICheckService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
// For verification, throws an error if monitor doesn't belong to team
// For verificaiton, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
throw new AppError({
message: `Missing required query parameters sortOrder: ${sortOrder}, dateRange: ${dateRange}, filter: ${filter}, page: ${page}, rowsPerPage: ${rowsPerPage}`,
service: SERVICE_NAME,
method: "getChecksByMonitor",
status: 400,
});
}
let { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status);
const parsedPage = page ? parseInt(page) : 0;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus);
return result;
};
getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record<string, string> }) => {
const { sortOrder, dateRange, filter, page, rowsPerPage } = query;
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
}
getChecksByTeam = async ({ teamId, query }: { teamId: string; query: any }) => {
let { sortOrder, dateRange, filter, page, rowsPerPage } = query;
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
}
const parsedPage = page ? parseInt(page) : 0;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData;
@@ -175,7 +162,7 @@ export class CheckService implements ICheckService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
}
// For verification, throws an error if monitor doesn't belong to team
// For verificaiton, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId);
@@ -191,6 +178,8 @@ export class CheckService implements ICheckService {
};
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } });
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500 });
};
}
export default CheckService;
@@ -3,19 +3,15 @@ import os from "os";
const SERVICE_NAME = "diagnosticService";
export interface IDiagnosticService {
getCPUUsage(): Promise<{ userUsageMs: number; systemUsageMs: number; usagePercentage: number }>;
getSystemStats(): Promise<{
osStats: { freeMemoryBytes: number; totalMemoryBytes: number };
memoryUsage: Record<keyof NodeJS.MemoryUsage, number>;
cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number };
v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number };
eventLoopDelayMs: number;
uptimeMs: number;
}>;
interface MemoryUsageMetrics {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
arrayBuffers: number;
}
export class DiagnosticService implements IDiagnosticService {
class DiagnosticService {
static SERVICE_NAME = SERVICE_NAME;
constructor() {
@@ -107,3 +103,5 @@ export class DiagnosticService implements IDiagnosticService {
return diagnostics;
};
}
export default DiagnosticService;
@@ -1,177 +0,0 @@
import type { Monitor, GeoCheck } from "@/types/index.js";
import type { GeoCheckResult } from "@/types/geoCheck.js";
import { Types } from "mongoose";
import type { IGeoChecksRepository } from "@/repositories/index.js";
import type { IMonitorsRepository } from "@/repositories/index.js";
import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js";
import type { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "GeoChecksService";
export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise<any>;
}
export class GeoChecksService implements IGeoChecksService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private geoChecksRepository: IGeoChecksRepository;
private globalPingService: IGlobalPingService;
private monitorsRepository: IMonitorsRepository;
constructor({
logger,
geoChecksRepository,
globalPingService,
monitorsRepository,
}: {
logger: ILogger;
geoChecksRepository: IGeoChecksRepository;
globalPingService: IGlobalPingService;
monitorsRepository: IMonitorsRepository;
}) {
this.logger = logger;
this.geoChecksRepository = geoChecksRepository;
this.globalPingService = globalPingService;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return GeoChecksService.SERVICE_NAME;
}
async buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null> {
try {
if (!monitor.url) {
this.logger.warn({
message: "Monitor missing URL for geo check",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id },
});
return null;
}
if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) {
this.logger.warn({
message: "Monitor missing geo check locations",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id },
});
return null;
}
// Step 1: Create measurement request
const measurementId = await this.globalPingService.createMeasurement(monitor.type, monitor.url, monitor.geoCheckLocations);
if (!measurementId) {
// GlobalPing API is down, skip this check
this.logger.debug({
message: "Skipping geo check due to API unavailability",
service: SERVICE_NAME,
method: "buildGeoCheck",
});
return null;
}
// Step 2: Poll for results
const results = await this.globalPingService.pollForResults(measurementId);
if (results.length === 0) {
// No successful results (all locations timed out or failed)
this.logger.debug({
message: "No successful geo check results",
service: SERVICE_NAME,
method: "buildGeoCheck",
});
return null;
}
// Step 3: Build GeoCheck document
const geoCheck = this.createGeoCheckDocument(monitor, results);
this.logger.debug({
message: `Geo check completed for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "buildGeoCheck",
});
return geoCheck;
} catch (error: unknown) {
this.logger.error({
message: "Error executing geo check",
service: SERVICE_NAME,
method: "buildGeoCheck",
details: { monitorId: monitor.id, error: error instanceof Error ? error.message : "Unknown error" },
stack: error instanceof Error ? error.stack : undefined,
});
return null;
}
}
private createGeoCheckDocument(monitor: Monitor, results: GeoCheckResult[]): GeoCheck {
const now = new Date();
const ttl = 90 * 24 * 60 * 60 * 1000; // 90 days in ms
const expiryDate = new Date(now.getTime() + ttl);
return {
id: new Types.ObjectId().toString(),
metadata: {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
},
results,
expiry: expiryDate.toISOString(),
__v: 0,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
}
createGeoChecks = async (geoChecks: GeoCheck[]) => {
return this.geoChecksRepository.createGeoChecks(geoChecks);
};
getGeoChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
if (!monitorId) {
throw new AppError({
message: "No monitor ID in request",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 400,
});
}
if (!teamId) {
throw new AppError({
message: "No team ID in request",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 400,
});
}
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({
message: `Monitor with ID ${monitorId} not found.`,
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 404,
});
}
const { sortOrder, dateRange, page, rowsPerPage, continent } = query;
const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined;
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents);
return result;
};
}
+32 -39
View File
@@ -3,51 +3,42 @@ import type { Monitor } from "@/types/monitor.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { getDateForRange } from "@/utils/dataUtils.js";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
import type { Incident, IncidentSummary, User } from "@/types/index.js";
import type { Incident } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
import type { ILogger } from "@/utils/logger.js";
export interface IIncidentService {
handleIncident(
monitor: Monitor,
code: number,
decision: MonitorActionDecision,
monitorStatusResponse?: MonitorStatusResponse
): Promise<Incident | null>;
resolveIncident(incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string): Promise<Incident>;
getIncidentsByTeam(
teamId: string,
sortOrder: string,
dateRange: string,
page: string,
rowsPerPage: string,
status: string,
monitorId: string,
resolutionType: string
): Promise<{ incidents: Incident[]; count: number }>;
getIncidentSummary(teamId: string, limit?: string): Promise<IncidentSummary>;
getIncidentById(incidentId: string, teamId: string): Promise<{ incident: Incident; monitor: Monitor; user: User | null }>;
}
const dateRangeLookup: Record<string, Date | undefined> = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
hour: new Date(new Date().setHours(new Date().getHours() - 1)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: undefined,
};
export class IncidentService implements IIncidentService {
class IncidentService {
static SERVICE_NAME = SERVICE_NAME;
private logger: ILogger;
private logger: any;
private incidentsRepository: IIncidentsRepository;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private notificationMessageBuilder: INotificationMessageBuilder;
constructor(
logger: ILogger,
incidentsRepository: IIncidentsRepository,
monitorsRepository: IMonitorsRepository,
usersRepository: IUsersRepository,
notificationMessageBuilder: INotificationMessageBuilder
) {
constructor({
logger,
incidentsRepository,
monitorsRepository,
usersRepository,
notificationMessageBuilder,
}: {
logger: any;
incidentsRepository: IIncidentsRepository;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
notificationMessageBuilder: INotificationMessageBuilder;
}) {
this.logger = logger;
this.incidentsRepository = incidentsRepository;
this.monitorsRepository = monitorsRepository;
@@ -160,7 +151,7 @@ export class IncidentService implements IIncidentService {
service: SERVICE_NAME,
method: "resolveIncidentManually",
message: `Incident manually resolved by user`,
details: { incidentId: resolvedIncident.id },
details: resolvedIncident.id,
});
return resolvedIncident;
@@ -169,7 +160,7 @@ export class IncidentService implements IIncidentService {
service: SERVICE_NAME,
method: "resolveIncident",
message: error.message,
details: { id: incidentId },
details: incidentId,
stack: error.stack,
});
throw error;
@@ -191,7 +182,7 @@ export class IncidentService implements IIncidentService {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getIncidentsByTeam", status: 400 });
}
const startDate = getDateForRange(dateRange);
const startDate = dateRangeLookup[dateRange];
const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0;
const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20;
@@ -216,7 +207,7 @@ export class IncidentService implements IIncidentService {
service: SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
details: { teamId },
details: teamId,
stack: error.stack,
});
throw error;
@@ -238,7 +229,7 @@ export class IncidentService implements IIncidentService {
service: SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
details: { teamId },
details: teamId,
stack: error.stack,
});
throw error;
@@ -259,10 +250,12 @@ export class IncidentService implements IIncidentService {
service: SERVICE_NAME,
method: "getIncidentById",
message: error.message,
details: { incidentId },
details: incidentId,
stack: error.stack,
});
throw error;
}
};
}
export default IncidentService;
+3 -7
View File
@@ -5,13 +5,7 @@ import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "inviteService";
export interface IInviteService {
getInviteToken(params: { invite: Partial<Invite>; teamId: string; userRoles: UserRole[] }): Promise<Invite>;
sendInviteEmail(params: { invite: Partial<Invite>; firstName: string; userRoles: UserRole[] }): Promise<void>;
verifyInviteToken(params: { inviteToken: string }): Promise<Invite>;
}
export class InviteService implements IInviteService {
class InviteService {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: any;
@@ -94,3 +88,5 @@ export class InviteService implements IInviteService {
return await this.invitesRepository.findByToken(inviteToken);
};
}
export default InviteService;
@@ -1,23 +1,10 @@
import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MaintenanceWindow } from "@/types/index.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "maintenanceWindowService";
export interface IMaintenanceWindowService {
createMaintenanceWindow(params: { teamId: string; body: Partial<MaintenanceWindow> & { monitors: string[] } }): Promise<void>;
getMaintenanceWindowById(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
getMaintenanceWindowsByTeamId(params: {
teamId: string;
query: Record<string, string>;
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
editMaintenanceWindow(params: { id: string; teamId: string; body: Partial<MaintenanceWindow> }): Promise<MaintenanceWindow>;
}
export class MaintenanceWindowService implements IMaintenanceWindowService {
class MaintenanceWindowService {
static SERVICE_NAME = SERVICE_NAME;
private monitorsRepository: IMonitorsRepository;
private maintenanceWindowsRepository: IMaintenanceWindowsRepository;
@@ -96,3 +83,5 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
};
}
export default MaintenanceWindowService;
+1 -54
View File
@@ -8,11 +8,8 @@ import type {
PageSpeedDetailsResult,
GamesMap,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
IGeoChecksRepository,
IIncidentsRepository,
IMonitorsRepository,
IMonitorStatsRepository,
@@ -39,7 +36,6 @@ export interface IMonitorService {
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
@@ -88,7 +84,6 @@ export class MonitorService implements IMonitorService {
private games: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private geoChecksRepository: IGeoChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
@@ -100,7 +95,6 @@ export class MonitorService implements IMonitorService {
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -111,7 +105,6 @@ export class MonitorService implements IMonitorService {
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
@@ -122,7 +115,6 @@ export class MonitorService implements IMonitorService {
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.geoChecksRepository = geoChecksRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
@@ -226,9 +218,7 @@ export class MonitorService implements IMonitorService {
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game" &&
checksData.monitorType !== "grpc" &&
checksData.monitorType !== "websocket"
checksData.monitorType !== "game"
) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
@@ -325,40 +315,6 @@ export class MonitorService implements IMonitorService {
monitorStats,
};
};
getGeoChecksByMonitorId = async ({
teamId,
monitorId,
dateRange,
continents,
}: {
teamId: string;
monitorId: string;
dateRange: string;
continents?: GeoContinent[];
}): Promise<any> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (!supportsGeoCheck(monitor.type) || !monitor.geoCheckEnabled) {
return { groupedGeoChecks: [] };
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
start,
end,
this.getDateFormat(rangeKey),
continents
);
return { groupedGeoChecks };
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
@@ -482,14 +438,6 @@ export class MonitorService implements IMonitorService {
});
});
await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
this.logger.warn({
message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
});
});
await this.jobQueue.deleteJob(monitor);
return monitor;
};
@@ -501,7 +449,6 @@ export class MonitorService implements IMonitorService {
try {
await this.jobQueue.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
} catch (error: any) {
+25 -97
View File
@@ -11,32 +11,10 @@ import { canManageRole, type UserRole } from "@/types/user.js";
import bcrypt from "bcryptjs";
import { AppError } from "@/utils/AppError.js";
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "@/service/infrastructure/emailService.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import jwt from "jsonwebtoken";
type JwtType = typeof jwt;
const SERVICE_NAME = "userService";
export interface IUserService {
issueToken(payload: Partial<User>, appSettings: { jwtTTL?: string; jwtSecret?: string }): string;
registerUser(user: Partial<User>, inviteToken: string, file: unknown): Promise<{ user: User; token: string }>;
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: unknown): Promise<User>;
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser(updates: Partial<User & { newPassword?: string }>, file: unknown, currentUser: User): Promise<User>;
checkSuperadminExists(): Promise<boolean>;
requestRecovery(email: string): Promise<string | false | undefined>;
validateRecovery(recoveryToken: string): Promise<void>;
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
deleteUser(user: User): Promise<void>;
deleteUserById(actor: User, targetUserId: string): Promise<void>;
getAllUsers(): Promise<User[]>;
getUserById(roles: UserRole[], userId: string): Promise<User>;
editUserById(userId: string, patch: Partial<User>): Promise<void>;
setPasswordByUserId(userId: string, password: string): Promise<User>;
}
export class UserService implements IUserService {
class UserService {
static SERVICE_NAME = SERVICE_NAME;
private hashPassword = (password: string): string => {
@@ -44,10 +22,10 @@ export class UserService implements IUserService {
return bcrypt.hashSync(password, salt);
};
private emailService: IEmailService;
private settingsService: ISettingsService;
private logger: ILogger;
private jwt: JwtType;
private emailService: any;
private settingsService: any;
private logger: any;
private jwt: any;
private jobQueue: ISuperSimpleQueue;
private crypto: any;
private monitorsRepository: IMonitorsRepository;
@@ -72,10 +50,10 @@ export class UserService implements IUserService {
teamsRepository,
}: {
crypto: any;
emailService: IEmailService;
settingsService: ISettingsService;
logger: ILogger;
jwt: JwtType;
emailService: any;
settingsService: any;
logger: any;
jwt: any;
jobQueue: ISuperSimpleQueue;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
@@ -142,7 +120,7 @@ export class UserService implements IUserService {
message: "New user created",
service: SERVICE_NAME,
method: "registerUser",
details: { userId: newUser.id },
details: newUser.id,
});
delete newUser.profileImage;
@@ -156,23 +134,20 @@ export class UserService implements IUserService {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
name: newUser.firstName,
});
if (!html) {
throw new Error("Failed to build welcome email HTML");
}
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error: unknown) => {
this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error: any) => {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
});
} catch (error: unknown) {
} catch (error: any) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
stack: error instanceof Error ? error.stack : undefined,
stack: error.stack,
});
}
@@ -206,7 +181,7 @@ export class UserService implements IUserService {
message: "New user created by superadmin",
service: SERVICE_NAME,
method: "createUser",
details: { userId: newUser.id },
details: newUser.id,
});
newUser.profileImage = undefined;
@@ -281,14 +256,6 @@ export class UserService implements IUserService {
email,
url,
});
if (!html) {
throw new AppError({
message: "Failed to build password reset email HTML",
service: SERVICE_NAME,
method: "requestRecovery",
status: 500,
});
}
const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html);
return msgId;
};
@@ -346,65 +313,25 @@ export class UserService implements IUserService {
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res &&
res?.length > 0 &&
(await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor.id);
})
);
}
));
}
// 6. Delete the user by id
await this.usersRepository.deleteById(userId);
};
deleteUserById = async (actor: User, targetUserId: string) => {
if (actor.id === targetUserId) {
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const targetUser = await this.usersRepository.findById(targetUserId);
if (targetUser.teamId !== actor.teamId) {
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
}
if (targetUser.role.includes("demo")) {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const actorRoles = actor.role;
const targetRoles = targetUser.role;
// Check actor can manage all of target's roles
for (const targetRole of targetRoles) {
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
if (!canManage) {
throw new AppError({
message: "You do not have permission to remove this user",
service: SERVICE_NAME,
method: "deleteUserById",
status: 403,
});
}
}
await this.usersRepository.deleteById(targetUserId);
this.logger.info({
message: `User ${targetUserId} deleted by ${actor.id}`,
service: SERVICE_NAME,
method: "deleteUserById",
});
};
getAllUsers = async () => {
return await this.usersRepository.findAll();
};
getUserById = async (roles: any, userId: any) => {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
if (!roles.includes("superadmin")) {
throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
@@ -421,3 +348,4 @@ export class UserService implements IUserService {
return updatedUser;
};
}
export default UserService;
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more