Merge branch 'bluewave-labs:develop' into develop
This commit is contained in:
64 files changed
+2471
-468
No files matched your search
@@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Frontend conventions (mandatory for `client/src`)
|
||||
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
|
||||
### Coding conventions (mandatory)
|
||||
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
|
||||
|
||||
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
|
||||
|
||||
**Frontend (`client/src`):**
|
||||
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
|
||||
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
|
||||
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
|
||||
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
|
||||
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
|
||||
|
||||
**Backend (`server/src`):**
|
||||
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
|
||||
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
|
||||
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
|
||||
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
|
||||
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
|
||||
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
|
||||
|
||||
### Internationalization
|
||||
All user-facing strings must use the translation function:
|
||||
```javascript
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatStatusCode,
|
||||
getStatusCodeTooltip,
|
||||
getStatusCodeValueType,
|
||||
} from "@/Utils/statusCode";
|
||||
import { ValueLabel } from "./StatusLabel";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export type StatusCodeLabelProps = {
|
||||
statusCode: number | null | undefined;
|
||||
message?: string | null;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
export const StatusCodeLabel = ({
|
||||
statusCode,
|
||||
message,
|
||||
fallback = "N/A",
|
||||
}: StatusCodeLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!statusCode) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
const text = formatStatusCode(statusCode, t);
|
||||
const tooltip = getStatusCodeTooltip(statusCode, message, t);
|
||||
const body = (
|
||||
<ValueLabel
|
||||
value={getStatusCodeValueType(statusCode)}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
if (!tooltip) return body;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{body}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
mobileLabel?: React.ReactNode;
|
||||
hideMobileLabel?: boolean;
|
||||
align?: "left" | "center" | "right" | "justify" | "inherit";
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
@@ -109,6 +112,24 @@ export function DataTable<
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
if (header.hideMobileLabel) {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={12}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
{header.render(row)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
@@ -123,7 +144,7 @@ export function DataTable<
|
||||
component="div"
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.content}
|
||||
{header.mobileLabel ?? header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
@@ -193,7 +214,7 @@ export function DataTable<
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
align={header.align ?? (idx === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
@@ -222,7 +243,7 @@ export function DataTable<
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={index === 0 ? "left" : "center"}
|
||||
align={header.align ?? (index === 0 ? "left" : "center")}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
|
||||
@@ -25,3 +25,4 @@ export * from "./TextLink";
|
||||
export * from "./NoticeBanner";
|
||||
export * from "./OfflineBanner";
|
||||
export * from "./Avatar";
|
||||
export * from "./StatusCodeLabel";
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useSidebar } from "@/Hooks/useSidebar";
|
||||
|
||||
interface BulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
onCancel: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BulkActionsBar = ({
|
||||
selectedCount,
|
||||
onCancel,
|
||||
children,
|
||||
}: BulkActionsBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isOpen = selectedCount > 0;
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const { width, collapsedWidth, transition } = useSidebar();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: theme.spacing(LAYOUT.XS),
|
||||
left: isSmall ? collapsedWidth : width,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
transition: transition.replace("width", "left"),
|
||||
}}
|
||||
>
|
||||
<Slide
|
||||
direction="up"
|
||||
in={isOpen}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
px: LAYOUT.MD,
|
||||
py: LAYOUT.XS,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: LAYOUT.XS,
|
||||
boxShadow: theme.shadows[8],
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={600}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{t("pages.common.monitors.actions.bulkSelected", {
|
||||
count: selectedCount,
|
||||
})}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onCancel}
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Slide>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ 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 { useTranslation } from "react-i18next";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
interface GeoChecksMapProps {
|
||||
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
||||
const theme = useTheme();
|
||||
const isDarkMode = theme.palette.mode === "dark";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mapPopupStyles = (
|
||||
<GlobalStyles
|
||||
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
>
|
||||
Status Code:
|
||||
</Typography>{" "}
|
||||
{selectedCheck.statusCode}
|
||||
{formatStatusCode(selectedCheck.statusCode, t)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { post } from "@/Utils/ApiClient";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: Monitor[];
|
||||
}
|
||||
|
||||
interface UseBulkMonitorActionsReturn {
|
||||
selectedRows: string[];
|
||||
setSelectedRows: (rows: string[]) => void;
|
||||
handleBulkPause: () => Promise<void>;
|
||||
handleBulkResume: () => Promise<void>;
|
||||
handleCancelSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBulkMonitorActions = (
|
||||
refetch: () => void,
|
||||
page?: number
|
||||
): UseBulkMonitorActionsReturn => {
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const { toastSuccess, toastError, toastInfo } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Clear selection when page changes
|
||||
useEffect(() => {
|
||||
setSelectedRows([]);
|
||||
}, [page]);
|
||||
|
||||
const executeBulkAction = async (pause: boolean) => {
|
||||
try {
|
||||
const res = await post<ApiResponse>("/monitors/bulk/pause", {
|
||||
monitorIds: selectedRows,
|
||||
pause,
|
||||
});
|
||||
|
||||
const affectedCount = res.data?.data?.length ?? 0;
|
||||
|
||||
if (affectedCount === 0) {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.alreadyPaused"
|
||||
: "pages.common.monitors.bulkPause.alreadyRunning";
|
||||
toastInfo(t(key, { count: selectedRows.length }));
|
||||
} else {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.paused"
|
||||
: "pages.common.monitors.bulkPause.resumed";
|
||||
toastSuccess(t(key, { count: affectedCount }));
|
||||
}
|
||||
|
||||
setSelectedRows([]);
|
||||
refetch();
|
||||
} catch (err: unknown) {
|
||||
let errMsg = "An error occurred";
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
errMsg = err.response?.data?.msg || err.message || errMsg;
|
||||
} else if (err instanceof Error) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
|
||||
pause,
|
||||
});
|
||||
toastError(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPause = async () => {
|
||||
await executeBulkAction(true);
|
||||
};
|
||||
|
||||
const handleBulkResume = async () => {
|
||||
await executeBulkAction(false);
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => {
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -29,7 +33,7 @@ export const ChecksTable = ({
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const getHeaders = () => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "monitorName",
|
||||
@@ -63,13 +67,10 @@ export const ChecksTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -85,7 +86,7 @@ export const ChecksTable = ({
|
||||
return headers;
|
||||
};
|
||||
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
const headers = getHeaders();
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
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 { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
if (!incident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
@@ -123,7 +124,12 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
||||
<Cell>
|
||||
<StatusCodeLabel
|
||||
statusCode={incident.statusCode}
|
||||
message={incident.message}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { Pagination } from "@/Components/design-elements/Table";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ValueLabel } from "@/Components/design-elements";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import { ActionsMenu } from "@/Components/actions-menu";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
color={
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
textTransform={"capitalize"}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.incidents.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
let value: ValueType = "neutral";
|
||||
if (code < 300) value = "positive";
|
||||
else if (code >= 400) value = "negative";
|
||||
return (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface InfraMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const InfraMonitorsTable = ({
|
||||
monitors,
|
||||
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: Function;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: InfraMonitorsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
post,
|
||||
// loading: isPatching,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
const handleSort = (e: any, field: string) => {
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -101,13 +123,6 @@ export const InfraMonitorsTable = ({
|
||||
navigate(`/infrastructure/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -152,8 +167,35 @@ export const InfraMonitorsTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
@@ -173,18 +215,20 @@ export const InfraMonitorsTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
@@ -248,6 +292,11 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
|
||||
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
|
||||
const isLoading = monitorsWithChecksLoading;
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
|
||||
|
||||
@@ -159,6 +175,29 @@ const InfrastructureMonitors = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<InfraMonitorsTable
|
||||
monitors={monitorsWithChecksData?.monitors || []}
|
||||
refetch={refetch}
|
||||
@@ -180,6 +219,8 @@ const InfrastructureMonitors = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue";
|
||||
import { Metrics } from "@/Pages/Logs/components/Metrics";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { EmptyState } from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
export const TabQueue = () => {
|
||||
const theme = useTheme();
|
||||
@@ -19,7 +20,7 @@ export const TabQueue = () => {
|
||||
isLoading,
|
||||
// error,
|
||||
refetch,
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
|
||||
|
||||
const { post, loading: isFlushing } = usePost();
|
||||
|
||||
@@ -49,32 +50,22 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.logs.jobQueue")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
<Trans
|
||||
i18nKey="pages.logs.jobQueueExplainer"
|
||||
components={{
|
||||
highlight: (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(19, 113, 91, 0.18)"
|
||||
: "#ECF7F2",
|
||||
color: theme.palette.text.primary,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
bgcolor={theme.palette.rowStatus.running}
|
||||
color={theme.palette.text.primary}
|
||||
px={SPACING.SM}
|
||||
py={SPACING.XXS}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
@@ -87,16 +78,11 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{t("pages.logs.failedJobs")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.logs.failedJobsExplainer")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
};
|
||||
|
||||
const headers: Header<QueueJobWithId>[] = [
|
||||
{
|
||||
id: "state",
|
||||
content: t("pages.logs.table.headers.state"),
|
||||
render: (row) => {
|
||||
if (row.lockedAt) {
|
||||
return t("common.labels.running");
|
||||
}
|
||||
if (row.monitorActive === true) {
|
||||
return t("common.labels.active");
|
||||
}
|
||||
if (row.monitorActive === false) {
|
||||
return t("common.labels.paused");
|
||||
}
|
||||
return t("common.labels.idle");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
content: t("common.table.headers.monitorId"),
|
||||
@@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
id: "url",
|
||||
content: t("common.table.headers.url"),
|
||||
render: (row) => (
|
||||
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
|
||||
<Typography
|
||||
title={row.monitorUrl ?? ""}
|
||||
sx={cellSx}
|
||||
>
|
||||
{row.monitorUrl}
|
||||
</Typography>
|
||||
),
|
||||
@@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
{
|
||||
id: "interval",
|
||||
content: t("common.table.headers.interval"),
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
let interval;
|
||||
if (row.monitorId.toString().includes("geo")) {
|
||||
interval = row.monitorGeoInterval ?? 0;
|
||||
} else {
|
||||
interval = row.repeat ?? 0;
|
||||
}
|
||||
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
@@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
||||
return (
|
||||
<Typography title={v} sx={cellSx}>
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lockedAt) ?? "-";
|
||||
return (
|
||||
<Typography title={v} sx={cellSx}>
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -98,16 +129,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
|
||||
|
||||
return (
|
||||
<Table
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
"& td": { backgroundColor: runningBg },
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
payload.expiry = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return row.statusCode || "N/A";
|
||||
return (
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
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: Header<FlatGeoCheck>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return row.statusCode || "N/A";
|
||||
return <StatusCodeLabel statusCode={row.statusCode} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
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,
|
||||
|
||||
@@ -20,6 +20,24 @@ import { useSelector } from "react-redux";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface MonitorTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const MonitorTable = ({
|
||||
monitors,
|
||||
@@ -34,31 +52,34 @@ export const MonitorTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: MonitorTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
||||
const {
|
||||
post,
|
||||
// loading: isPosting,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const handleSort = (e: any, field: string) => {
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -110,13 +131,6 @@ export const MonitorTable = ({
|
||||
navigate(`/uptime/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -160,8 +174,35 @@ export const MonitorTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -181,6 +222,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -212,6 +254,7 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
mobileLabel: t("common.table.headers.type"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
@@ -251,6 +294,11 @@ export const MonitorTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -16,6 +21,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,6 +43,7 @@ const UptimeMonitorsPage = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -102,6 +109,15 @@ const UptimeMonitorsPage = () => {
|
||||
count,
|
||||
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Delete hook
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
@@ -177,6 +193,29 @@ const UptimeMonitorsPage = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<MonitorTable
|
||||
monitors={monitorsWithChecks || []}
|
||||
refetch={refetch}
|
||||
@@ -198,6 +237,8 @@ const UptimeMonitorsPage = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface QueueJobSummary {
|
||||
monitorUrl: string | null;
|
||||
monitorType: MonitorType | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
@@ -32,6 +34,7 @@ export interface QueueJobSummary {
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
}
|
||||
|
||||
export interface QueueData {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
const typographyBase = 13;
|
||||
|
||||
export const typographyLevels = {
|
||||
@@ -17,6 +19,9 @@ export const colors = {
|
||||
gray850: "#1c1c21",
|
||||
brandGreen: "#13715B",
|
||||
brandGreenLight: "#4DAF94",
|
||||
green200: "#ECF7F2",
|
||||
yellow200: "#FFF4E5",
|
||||
orange500: "#FFA500",
|
||||
};
|
||||
|
||||
export const lightPalette = {
|
||||
@@ -29,6 +34,10 @@ export const lightPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreen,
|
||||
},
|
||||
rowStatus: {
|
||||
running: colors.green200,
|
||||
paused: colors.yellow200,
|
||||
},
|
||||
};
|
||||
|
||||
export const darkPalette = {
|
||||
@@ -45,4 +54,8 @@ export const darkPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreenLight,
|
||||
},
|
||||
rowStatus: {
|
||||
running: alpha(colors.brandGreen, 0.18),
|
||||
paused: alpha(colors.orange500, 0.18),
|
||||
},
|
||||
};
|
||||
@@ -15,9 +15,11 @@ declare module "@mui/material/styles" {
|
||||
}
|
||||
interface Palette {
|
||||
sidebar: { accent: string };
|
||||
rowStatus: { running: string; paused: string };
|
||||
}
|
||||
interface PaletteOptions {
|
||||
sidebar?: { accent: string };
|
||||
rowStatus?: { running: string; paused: string };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const SPACING = {
|
||||
XXS: 0.25,
|
||||
XS: 0.5,
|
||||
SM: 1,
|
||||
MD: 1.5,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
|
||||
export const NETWORK_ERROR_CODE = 5000;
|
||||
|
||||
export const NETWORK_ERROR_SUBTYPES = [
|
||||
"timeout",
|
||||
"dns",
|
||||
"refused",
|
||||
"tls",
|
||||
"reset",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number];
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
export const isNetworkError = (code?: number | null): boolean =>
|
||||
code === NETWORK_ERROR_CODE;
|
||||
|
||||
const ERROR_CODE_PATTERN =
|
||||
/\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/;
|
||||
|
||||
export const extractErrorCode = (message?: string | null): string | null => {
|
||||
if (!message) return null;
|
||||
const match = message.match(ERROR_CODE_PATTERN);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => {
|
||||
if (!message) return "unknown";
|
||||
const m = message.toUpperCase();
|
||||
if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT"))
|
||||
return "timeout";
|
||||
if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS"))
|
||||
return "dns";
|
||||
if (m.includes("ECONNREFUSED")) return "refused";
|
||||
if (
|
||||
m.includes("CERT_") ||
|
||||
m.includes("UNABLE_TO_VERIFY") ||
|
||||
m.includes("DEPTH_ZERO_SELF_SIGNED") ||
|
||||
m.includes("SELF_SIGNED") ||
|
||||
m.includes("TLS") ||
|
||||
m.includes("SSL")
|
||||
)
|
||||
return "tls";
|
||||
if (m.includes("ECONNRESET")) return "reset";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const formatStatusCode = (
|
||||
code: number | null | undefined,
|
||||
t: TranslateFn
|
||||
): string => {
|
||||
if (isNetworkError(code)) return t("common.statusCode.down");
|
||||
return String(code);
|
||||
};
|
||||
|
||||
export const getStatusCodeTooltip = (
|
||||
code: number | null | undefined,
|
||||
message: string | null | undefined,
|
||||
t: TranslateFn
|
||||
): string | null => {
|
||||
if (!isNetworkError(code)) return null;
|
||||
const description = t(
|
||||
`common.statusCode.networkError.${getNetworkErrorSubtype(message)}`
|
||||
);
|
||||
const errorCode = extractErrorCode(message);
|
||||
return errorCode ? `${description} (${errorCode})` : description;
|
||||
};
|
||||
|
||||
export const getStatusCodeValueType = (code: number): ValueType => {
|
||||
if (isNetworkError(code)) return "negative";
|
||||
if (code < 300) return "positive";
|
||||
if (code < 400) return "neutral";
|
||||
return "negative";
|
||||
};
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "دعوة عضو",
|
||||
"openSite": "فتح الموقع",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"resume": "استئناف"
|
||||
"resume": "استئناف",
|
||||
"selectAll": "تحديد جميع الشاشات",
|
||||
"selectMonitor": "تحديد {{name}}",
|
||||
"bulkSelected_one": "{{count}} شاشة محددة",
|
||||
"bulkSelected_other": "{{count}} شاشات محددة"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "نشط منذ",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "البنية التحتية",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
|
||||
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
|
||||
"resumed_one": "تم استئناف {{count}} شاشة",
|
||||
"resumed_other": "تم استئناف {{count}} شاشات",
|
||||
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
|
||||
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Pozvat člena",
|
||||
"openSite": "Otevřít stránku",
|
||||
"pause": "Pozastavit",
|
||||
"resume": "Obnovit"
|
||||
"resume": "Obnovit",
|
||||
"selectAll": "Vybrat všechny monitory",
|
||||
"selectMonitor": "Vybrat {{name}}",
|
||||
"bulkSelected_one": "Vybrán {{count}} monitor",
|
||||
"bulkSelected_other": "Vybráno {{count}} monitorů"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktivní po dobu",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pozastaven",
|
||||
"paused_other": "{{count}} monitorů pozastaveno",
|
||||
"resumed_one": "{{count}} monitor obnoven",
|
||||
"resumed_other": "{{count}} monitorů obnoveno",
|
||||
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
|
||||
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
|
||||
"alreadyRunning_one": "Vybraný monitor již běží",
|
||||
"alreadyRunning_other": "Všechny vybrané monitory již běží"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Mitglied einladen",
|
||||
"openSite": "Seite öffnen",
|
||||
"pause": "Pausieren",
|
||||
"resume": "Fortsetzen"
|
||||
"resume": "Fortsetzen",
|
||||
"selectAll": "Alle Monitore auswählen",
|
||||
"selectMonitor": "{{name}} auswählen",
|
||||
"bulkSelected_one": "{{count}} Monitor ausgewählt",
|
||||
"bulkSelected_other": "{{count}} Monitore ausgewählt"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiv seit",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktur",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} Monitor pausiert",
|
||||
"paused_other": "{{count}} Monitore pausiert",
|
||||
"resumed_one": "{{count}} Monitor fortgesetzt",
|
||||
"resumed_other": "{{count}} Monitore fortgesetzt",
|
||||
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
|
||||
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
|
||||
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
|
||||
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
}
|
||||
},
|
||||
"appName": "Checkmate",
|
||||
"statusCode": {
|
||||
"down": "Down",
|
||||
"networkError": {
|
||||
"timeout": "The server didn't respond in time (request timed out).",
|
||||
"dns": "Couldn't resolve the hostname (DNS lookup failed).",
|
||||
"refused": "The server refused the connection (port closed or service not listening).",
|
||||
"tls": "TLS or SSL handshake failed (certificate or encryption issue).",
|
||||
"reset": "The connection was reset before a response was received.",
|
||||
"unknown": "No HTTP response was received from the target."
|
||||
}
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"details": "Details",
|
||||
"home": "Home"
|
||||
@@ -70,7 +81,9 @@
|
||||
},
|
||||
"labels": {
|
||||
"active": "Active",
|
||||
"paused": "paused",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"idle": "Idle",
|
||||
"na": "N/A",
|
||||
"resolved": "Resolved",
|
||||
"responseTime": "Response time"
|
||||
@@ -421,7 +434,21 @@
|
||||
"inviteMember": "Invite member",
|
||||
"openSite": "Open site",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume"
|
||||
"resume": "Resume",
|
||||
"bulkSelected_one": "{{count}} monitor selected",
|
||||
"bulkSelected_other": "{{count}} monitors selected",
|
||||
"selectAll": "Select all monitors",
|
||||
"selectMonitor": "Select {{name}}"
|
||||
},
|
||||
"bulkPause": {
|
||||
"alreadyPaused_one": "The selected monitor is already paused",
|
||||
"alreadyPaused_other": "All selected monitors are already paused",
|
||||
"alreadyRunning_one": "The selected monitor is already running",
|
||||
"alreadyRunning_other": "All selected monitors are already running",
|
||||
"paused_one": "{{count}} monitor paused",
|
||||
"paused_other": "{{count}} monitors paused",
|
||||
"resumed_one": "{{count}} monitor resumed",
|
||||
"resumed_other": "{{count}} monitors resumed"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Active for",
|
||||
@@ -887,6 +914,7 @@
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"state": "State",
|
||||
"timestamp": "Timestamp",
|
||||
"level": "Level",
|
||||
"service": "Service",
|
||||
|
||||
@@ -428,7 +428,11 @@
|
||||
"inviteMember": "Invitar miembro",
|
||||
"openSite": "Abrir sitio",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar"
|
||||
"resume": "Reanudar",
|
||||
"selectAll": "Seleccionar todos los monitores",
|
||||
"selectMonitor": "Seleccionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor seleccionado",
|
||||
"bulkSelected_other": "{{count}} monitores seleccionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Activo desde hace",
|
||||
@@ -456,6 +460,16 @@
|
||||
"optionHardware": "Infraestructura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor reanudado",
|
||||
"resumed_other": "{{count}} monitores reanudados",
|
||||
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
|
||||
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
|
||||
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
|
||||
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Kutsu jäsen",
|
||||
"openSite": "Avaa sivusto",
|
||||
"pause": "Keskeytä",
|
||||
"resume": "Jatka"
|
||||
"resume": "Jatka",
|
||||
"selectAll": "Valitse kaikki näytöt",
|
||||
"selectMonitor": "Valitse {{name}}",
|
||||
"bulkSelected_one": "{{count}} näyttö valittu",
|
||||
"bulkSelected_other": "{{count}} näyttöä valittu"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiivinen",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastruktuuri",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} näyttö keskeytetty",
|
||||
"paused_other": "{{count}} näyttöä keskeytetty",
|
||||
"resumed_one": "{{count}} näyttö jatkettu",
|
||||
"resumed_other": "{{count}} näyttöä jatkettu",
|
||||
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
|
||||
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
|
||||
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
|
||||
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Inviter un membre",
|
||||
"openSite": "Ouvrir le site",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre"
|
||||
"resume": "Reprendre",
|
||||
"selectAll": "Sélectionner tous les moniteurs",
|
||||
"selectMonitor": "Sélectionner {{name}}",
|
||||
"bulkSelected_one": "{{count}} moniteur sélectionné",
|
||||
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Actif depuis",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} moniteur en pause",
|
||||
"paused_other": "{{count}} moniteurs en pause",
|
||||
"resumed_one": "{{count}} moniteur repris",
|
||||
"resumed_other": "{{count}} moniteurs repris",
|
||||
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
|
||||
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
|
||||
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
|
||||
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "メンバーを招待",
|
||||
"openSite": "サイトを開く",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開"
|
||||
"resume": "再開",
|
||||
"selectAll": "すべてのモニターを選択",
|
||||
"selectMonitor": "{{name}} を選択",
|
||||
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
|
||||
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "稼働期間",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "インフラストラクチャ",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} 個のモニターを一時停止しました",
|
||||
"paused_other": "{{count}} 個のモニターを一時停止しました",
|
||||
"resumed_one": "{{count}} 個のモニターを再開しました",
|
||||
"resumed_other": "{{count}} 個のモニターを再開しました",
|
||||
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
|
||||
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
|
||||
"alreadyRunning_one": "選択したモニターはすでに実行中です",
|
||||
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Convidar membro",
|
||||
"openSite": "Abrir site",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar"
|
||||
"resume": "Retomar",
|
||||
"selectAll": "Selecionar todos os monitores",
|
||||
"selectMonitor": "Selecionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor selecionado",
|
||||
"bulkSelected_other": "{{count}} monitores selecionados"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Ativo há",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Infraestrutura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor retomado",
|
||||
"resumed_other": "{{count}} monitores retomados",
|
||||
"alreadyPaused_one": "O monitor selecionado já está pausado",
|
||||
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
|
||||
"alreadyRunning_one": "O monitor selecionado já está em execução",
|
||||
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Пригласить участника",
|
||||
"openSite": "Открыть сайт",
|
||||
"pause": "Пауза",
|
||||
"resume": "Продолжить"
|
||||
"resume": "Продолжить",
|
||||
"selectAll": "Выбрать все мониторы",
|
||||
"selectMonitor": "Выбрать {{name}}",
|
||||
"bulkSelected_one": "Выбран {{count}} монитор",
|
||||
"bulkSelected_other": "Выбрано {{count}} мониторов"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активен",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Инфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монитор приостановлен",
|
||||
"paused_other": "{{count}} мониторов приостановлено",
|
||||
"resumed_one": "{{count}} монитор возобновлен",
|
||||
"resumed_other": "{{count}} мониторов возобновлено",
|
||||
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
|
||||
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
|
||||
"alreadyRunning_one": "Выбранный монитор уже запущен",
|
||||
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "เชิญสมาชิก",
|
||||
"openSite": "เปิดเว็บไซต์",
|
||||
"pause": "หยุดชั่วคราว",
|
||||
"resume": "ดำเนินการต่อ"
|
||||
"resume": "ดำเนินการต่อ",
|
||||
"selectAll": "เลือกจอภาพทั้งหมด",
|
||||
"selectMonitor": "เลือก {{name}}",
|
||||
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
|
||||
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "ทำงานมาแล้ว",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "โครงสร้างพื้นฐาน",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
|
||||
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Üye davet et",
|
||||
"openSite": "Siteyi aç",
|
||||
"pause": "Duraklat",
|
||||
"resume": "Devam et"
|
||||
"resume": "Devam et",
|
||||
"selectAll": "Tüm monitörleri seç",
|
||||
"selectMonitor": "{{name}}'i seç",
|
||||
"bulkSelected_one": "{{count}} monitör seçildi",
|
||||
"bulkSelected_other": "{{count}} monitör seçildi"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktif süresi",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Altyapı",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitör duraklatıldı",
|
||||
"paused_other": "{{count}} monitör duraklatıldı",
|
||||
"resumed_one": "{{count}} monitör sürdürüldü",
|
||||
"resumed_other": "{{count}} monitör sürdürüldü",
|
||||
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
|
||||
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
|
||||
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
|
||||
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Запросити учасника",
|
||||
"openSite": "Відкрити сайт",
|
||||
"pause": "Призупинити",
|
||||
"resume": "Відновити"
|
||||
"resume": "Відновити",
|
||||
"selectAll": "Вибрати всі монітори",
|
||||
"selectMonitor": "Вибрати {{name}}",
|
||||
"bulkSelected_one": "Вибрано {{count}} монітор",
|
||||
"bulkSelected_other": "Вибрано {{count}} моніторів"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активний протягом",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Інфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монітор призупинено",
|
||||
"paused_other": "{{count}} моніторів призупинено",
|
||||
"resumed_one": "{{count}} монітор відновлено",
|
||||
"resumed_other": "{{count}} моніторів відновлено",
|
||||
"alreadyPaused_one": "Вибраний монітор уже призупинено",
|
||||
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
|
||||
"alreadyRunning_one": "Вибраний монітор уже запущено",
|
||||
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "Mời thành viên",
|
||||
"openSite": "Mở trang web",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục"
|
||||
"resume": "Tiếp tục",
|
||||
"selectAll": "Chọn tất cả màn hình",
|
||||
"selectMonitor": "Chọn {{name}}",
|
||||
"bulkSelected_one": "Đã chọn {{count}} màn hình",
|
||||
"bulkSelected_other": "Đã chọn {{count}} màn hình"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Hoạt động từ",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "Hạ tầng",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "Đã tạm dừng {{count}} màn hình",
|
||||
"paused_other": "Đã tạm dừng {{count}} màn hình",
|
||||
"resumed_one": "Đã tiếp tục {{count}} màn hình",
|
||||
"resumed_other": "Đã tiếp tục {{count}} màn hình",
|
||||
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
|
||||
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀请成员",
|
||||
"openSite": "打开站点",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复"
|
||||
"resume": "恢复",
|
||||
"selectAll": "选择所有监视器",
|
||||
"selectMonitor": "选择 {{name}}",
|
||||
"bulkSelected_one": "已选择 {{count}} 个监视器",
|
||||
"bulkSelected_other": "已选择 {{count}} 个监视器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已活跃",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "基础设施",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暂停 {{count}} 个监视器",
|
||||
"paused_other": "已暂停 {{count}} 个监视器",
|
||||
"resumed_one": "已恢复 {{count}} 个监视器",
|
||||
"resumed_other": "已恢复 {{count}} 个监视器",
|
||||
"alreadyPaused_one": "所选监视器已暂停",
|
||||
"alreadyPaused_other": "所有选定的监视器均已暂停",
|
||||
"alreadyRunning_one": "所选监视器已经在运行",
|
||||
"alreadyRunning_other": "所有选定的监视器均已在运行"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,7 +421,11 @@
|
||||
"inviteMember": "邀請成員",
|
||||
"openSite": "開啟網站",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復"
|
||||
"resume": "恢復",
|
||||
"selectAll": "選擇所有監視器",
|
||||
"selectMonitor": "選擇 {{name}}",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個監視器",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個監視器"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已啟用",
|
||||
@@ -449,6 +453,16 @@
|
||||
"optionHardware": "基礎架構",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暫停 {{count}} 個監視器",
|
||||
"paused_other": "已暫停 {{count}} 個監視器",
|
||||
"resumed_one": "已恢復 {{count}} 個監視器",
|
||||
"resumed_other": "已恢復 {{count}} 個監視器",
|
||||
"alreadyPaused_one": "所選監視器已暫停",
|
||||
"alreadyPaused_other": "所有選定的監視器均已暫停",
|
||||
"alreadyRunning_one": "所選監視器已經在運行",
|
||||
"alreadyRunning_other": "所有選定的監視器均已在運行"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# Coding conventions
|
||||
|
||||
Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent.
|
||||
|
||||
> If you're touching a file in `client/src` the **frontend** rules apply.
|
||||
> If you're touching a file in `server/src` the **backend** rules apply.
|
||||
> Rule 0 applies everywhere.
|
||||
|
||||
---
|
||||
|
||||
## 0. Look at existing implementations and follow the established pattern
|
||||
|
||||
Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence.
|
||||
|
||||
**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately.
|
||||
|
||||
**How to apply:** before writing a new component, page, provider, repository, or test file:
|
||||
|
||||
1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider).
|
||||
2. Read at least two of them end-to-end.
|
||||
3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports.
|
||||
4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in.
|
||||
|
||||
**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure.
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Frontend (`client/src`)
|
||||
|
||||
## 1. Prefer MUI native (system) props over `sx`
|
||||
|
||||
MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
|
||||
|
||||
**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
|
||||
|
||||
**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
|
||||
|
||||
### Canonical mappings
|
||||
|
||||
| `sx` key | Native prop |
|
||||
|---|---|
|
||||
| `color` | `color` |
|
||||
| `backgroundColor` | `bgcolor` |
|
||||
| `borderRadius` | `borderRadius` |
|
||||
| `border`, `borderTop`, … | `border`, `borderTop`, … |
|
||||
| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
|
||||
| `p`, `pt`, `pb`, `px`, `py` | same names as native |
|
||||
| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
|
||||
| `display` | `display` |
|
||||
| `textAlign` | `textAlign` |
|
||||
| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
|
||||
| `gap` (on Stack) | `gap` |
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// BAD — everything inside sx
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${alpha(tone, 0.45)}`,
|
||||
backgroundColor: alpha(tone, 0.08),
|
||||
textAlign: "left",
|
||||
}}
|
||||
/>
|
||||
|
||||
// GOOD — every key that has a native prop is hoisted
|
||||
<Box
|
||||
width="100%"
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
border={`1px solid ${alpha(tone, 0.45)}`}
|
||||
bgcolor={alpha(tone, 0.08)}
|
||||
textAlign="left"
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
|
||||
|
||||
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.55}
|
||||
>
|
||||
```
|
||||
|
||||
### When `sx` is still correct
|
||||
|
||||
Keep `sx` for things that don't have a native prop:
|
||||
|
||||
```tsx
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color={theme.palette.primary.main}
|
||||
fontWeight={600}
|
||||
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
|
||||
/>
|
||||
```
|
||||
|
||||
Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use the full theme path for colors, never the shorthand string
|
||||
|
||||
Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
|
||||
|
||||
**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
|
||||
|
||||
**How to apply:** always destructure or reference the theme value explicitly.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Typography color="text.secondary">…</Typography>
|
||||
<Typography color="primary.main">…</Typography>
|
||||
|
||||
// GOOD
|
||||
const theme = useTheme();
|
||||
<Typography color={theme.palette.text.secondary}>…</Typography>
|
||||
<Typography color={theme.palette.primary.main}>…</Typography>
|
||||
```
|
||||
|
||||
This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
|
||||
|
||||
---
|
||||
|
||||
## 3. No hardcoded numeric or string literals — use tokens
|
||||
|
||||
The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
|
||||
|
||||
**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
|
||||
|
||||
**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
|
||||
|
||||
### Token sources
|
||||
|
||||
- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
|
||||
- `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
|
||||
- `SPACING` for `theme.spacing(...)` multipliers
|
||||
- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
|
||||
- `xs / s / m / l / xl / xxl` (rem values)
|
||||
- **Hover / interaction** — `HOVER.DARKEN` etc.
|
||||
- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
|
||||
- **Colors** — `theme.palette.*` (rule 2 above)
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Box sx={{ mt: "2px" }} />
|
||||
<Stack spacing={theme.spacing(2)} />
|
||||
<Box sx={{ borderRadius: 1 }} />
|
||||
<Typography fontSize={18} />
|
||||
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
|
||||
|
||||
// GOOD
|
||||
<Box mt={LAYOUT.XXS} />
|
||||
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
|
||||
<Box borderRadius={theme.shape.borderRadius} />
|
||||
<Typography fontSize={typographyLevels.xl} />
|
||||
<Typography /> // inherits 13px from theme
|
||||
```
|
||||
|
||||
### Token naming gotcha
|
||||
|
||||
`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
|
||||
|
||||
---
|
||||
|
||||
## 4. Use `useTheme()` inside components, don't import the theme module
|
||||
|
||||
The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
|
||||
|
||||
**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
|
||||
|
||||
**How to apply:** inside any component or component-helper that needs theme values:
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
import { theme } from "@/Utils/Theme/Theme";
|
||||
const SectionHeading = ({ children }) => (
|
||||
<Typography color={theme.palette.text.secondary}>{children}</Typography>
|
||||
);
|
||||
|
||||
// GOOD
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
const SectionHeading = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
|
||||
};
|
||||
```
|
||||
|
||||
Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Discriminated unions: pair the runtime list with the type
|
||||
|
||||
When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
|
||||
|
||||
**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
type Severity = "info" | "warning" | "error";
|
||||
|
||||
// GOOD
|
||||
export const Severities = ["info", "warning", "error"] as const;
|
||||
export type Severity = (typeof Severities)[number];
|
||||
```
|
||||
|
||||
Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Backend (`server/src`)
|
||||
|
||||
## 6. Layering: Controller → Service → Repository → Mongoose
|
||||
|
||||
The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts.
|
||||
|
||||
```
|
||||
Request → Controller → Service → Repository → MongoDB (Mongoose)
|
||||
```
|
||||
|
||||
**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite.
|
||||
|
||||
**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository.
|
||||
|
||||
- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic.
|
||||
- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB.
|
||||
- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper.
|
||||
|
||||
---
|
||||
|
||||
## 7. Provider conventions for `service/infrastructure/network/*`
|
||||
|
||||
Every monitor-type provider implements `IStatusProvider<TPayload>` and follows the same skeleton.
|
||||
|
||||
**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases.
|
||||
|
||||
**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton:
|
||||
|
||||
```ts
|
||||
const SERVICE_NAME = "XxxProvider";
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
type ClientCtor = typeof SomeNodeStdlibClass;
|
||||
|
||||
export class XxxProvider implements IStatusProvider<XxxStatusPayload> {
|
||||
readonly type = "xxx";
|
||||
|
||||
constructor(private Client: ClientCtor) {}
|
||||
|
||||
supports(type: MonitorType): boolean {
|
||||
return type === "xxx";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<XxxStatusPayload>> {
|
||||
try {
|
||||
// 1. validate inputs → throw AppError(400) on bad input
|
||||
// 2. set up the client (use this.Client, never imported singleton)
|
||||
// 3. perform the call inside timeRequest(...), with a setTimeout race
|
||||
// in the Promise body to enforce TIMEOUT_MS
|
||||
// 4. return MonitorStatusResponse with payload
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AppError) throw err;
|
||||
const originalMessage = err instanceof Error ? err.message : String(err);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing xxx check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { /* monitor-shaped context */ },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hard rules
|
||||
|
||||
- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`.
|
||||
- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body.
|
||||
- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`.
|
||||
- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern.
|
||||
- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mongoose schema fields with closed value sets
|
||||
|
||||
If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`.
|
||||
|
||||
**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers.
|
||||
|
||||
**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum:
|
||||
|
||||
```ts
|
||||
// types/monitor.ts (existing)
|
||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||
|
||||
// db/models/Monitor.ts
|
||||
{
|
||||
matchMethod: {
|
||||
type: String,
|
||||
enum: MonitorMatchMethods,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test conventions for `server/test/unit/providers/network/*`
|
||||
|
||||
Each provider gets a unit test file at `server/test/unit/providers/network/<providerName>.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`.
|
||||
|
||||
**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import.
|
||||
- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block.
|
||||
- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`).
|
||||
- **`makeMonitor` factory**: takes `Partial<Monitor>` overrides, spreads onto a baseline, returns `as Monitor`.
|
||||
- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation:
|
||||
|
||||
```ts
|
||||
const setup = () => {
|
||||
const mock = createMockClient();
|
||||
const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client;
|
||||
const provider = new XxxProvider(Ctor);
|
||||
return { mock, provider };
|
||||
};
|
||||
|
||||
it("does X", async () => {
|
||||
const { mock, provider } = setup();
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case.
|
||||
- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing").
|
||||
|
||||
---
|
||||
|
||||
## 10. Validation: centralize, don't inline
|
||||
|
||||
Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places.
|
||||
|
||||
**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce.
|
||||
|
||||
**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple.
|
||||
|
||||
---
|
||||
|
||||
## 11. OpenAPI: response schemas live in the validator file
|
||||
|
||||
The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit.
|
||||
|
||||
**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap.
|
||||
|
||||
**How to apply:** when you add an entity field, update three files in the same PR:
|
||||
|
||||
1. `types/<entity>.ts` — add the field to the TS interface.
|
||||
2. `db/models/<Entity>.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8).
|
||||
3. `validation/<entity>Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable).
|
||||
|
||||
The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one.
|
||||
|
||||
---
|
||||
|
||||
# Pre-PR checklist
|
||||
|
||||
Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`.
|
||||
|
||||
## Universal
|
||||
- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0).
|
||||
- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`.
|
||||
|
||||
## Frontend (`client/src`)
|
||||
- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1).
|
||||
- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2).
|
||||
- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3).
|
||||
- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4).
|
||||
- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5).
|
||||
|
||||
## Backend (`server/src`)
|
||||
- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6).
|
||||
- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7).
|
||||
- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7).
|
||||
- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8).
|
||||
- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9).
|
||||
- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10).
|
||||
- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11).
|
||||
|
||||
A clean PR on these axes is a PR that ships without a normalize follow-up.
|
||||
@@ -1,211 +0,0 @@
|
||||
# Frontend conventions
|
||||
|
||||
Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme.
|
||||
|
||||
> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prefer MUI native (system) props over `sx`
|
||||
|
||||
MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
|
||||
|
||||
**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
|
||||
|
||||
**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
|
||||
|
||||
### Canonical mappings
|
||||
|
||||
| `sx` key | Native prop |
|
||||
|---|---|
|
||||
| `color` | `color` |
|
||||
| `backgroundColor` | `bgcolor` |
|
||||
| `borderRadius` | `borderRadius` |
|
||||
| `border`, `borderTop`, … | `border`, `borderTop`, … |
|
||||
| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
|
||||
| `p`, `pt`, `pb`, `px`, `py` | same names as native |
|
||||
| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
|
||||
| `display` | `display` |
|
||||
| `textAlign` | `textAlign` |
|
||||
| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
|
||||
| `gap` (on Stack) | `gap` |
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// BAD — everything inside sx
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${alpha(tone, 0.45)}`,
|
||||
backgroundColor: alpha(tone, 0.08),
|
||||
textAlign: "left",
|
||||
}}
|
||||
/>
|
||||
|
||||
// GOOD — every key that has a native prop is hoisted
|
||||
<Box
|
||||
width={"100%"}
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
border={`1px solid ${alpha(tone, 0.45)}`}
|
||||
bgcolor={alpha(tone, 0.08)}
|
||||
textAlign={"left"}
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
|
||||
|
||||
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.55}
|
||||
>
|
||||
```
|
||||
|
||||
### When `sx` is still correct
|
||||
|
||||
Keep `sx` for things that don't have a native prop:
|
||||
|
||||
```tsx
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color={theme.palette.primary.main}
|
||||
fontWeight={600}
|
||||
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
|
||||
/>
|
||||
```
|
||||
|
||||
Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use the full theme path for colors, never the shorthand string
|
||||
|
||||
Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
|
||||
|
||||
**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
|
||||
|
||||
**How to apply:** always destructure or reference the theme value explicitly.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Typography color="text.secondary">…</Typography>
|
||||
<Typography color="primary.main">…</Typography>
|
||||
|
||||
// GOOD
|
||||
const theme = useTheme();
|
||||
<Typography color={theme.palette.text.secondary}>…</Typography>
|
||||
<Typography color={theme.palette.primary.main}>…</Typography>
|
||||
```
|
||||
|
||||
This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
|
||||
|
||||
---
|
||||
|
||||
## 3. No hardcoded numeric or string literals — use tokens
|
||||
|
||||
The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
|
||||
|
||||
**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
|
||||
|
||||
**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
|
||||
|
||||
### Token sources
|
||||
|
||||
- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
|
||||
- `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
|
||||
- `SPACING` for `theme.spacing(...)` multipliers
|
||||
- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
|
||||
- `xs / s / m / l / xl / xxl` (rem values)
|
||||
- **Hover / interaction** — `HOVER.DARKEN` etc.
|
||||
- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
|
||||
- **Colors** — `theme.palette.*` (rule 2 above)
|
||||
|
||||
### Examples
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
<Box sx={{ mt: "2px" }} />
|
||||
<Stack spacing={theme.spacing(2)} />
|
||||
<Box sx={{ borderRadius: 1 }} />
|
||||
<Typography fontSize={18} />
|
||||
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
|
||||
|
||||
// GOOD
|
||||
<Box mt={LAYOUT.XXS} />
|
||||
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
|
||||
<Box borderRadius={theme.shape.borderRadius} />
|
||||
<Typography fontSize={typographyLevels.xl} />
|
||||
<Typography /> // inherits 13px from theme
|
||||
```
|
||||
|
||||
### Token naming gotcha
|
||||
|
||||
`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
|
||||
|
||||
---
|
||||
|
||||
## 4. Use `useTheme()` inside components, don't import the theme module
|
||||
|
||||
The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
|
||||
|
||||
**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
|
||||
|
||||
**How to apply:** inside any component or component-helper that needs theme values:
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
import { theme } from "@/Utils/Theme/Theme";
|
||||
const SectionHeading = ({ children }) => (
|
||||
<Typography color={theme.palette.text.secondary}>{children}</Typography>
|
||||
);
|
||||
|
||||
// GOOD
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
const SectionHeading = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
|
||||
};
|
||||
```
|
||||
|
||||
Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Discriminated unions: pair the runtime list with the type
|
||||
|
||||
When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
|
||||
|
||||
**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
|
||||
|
||||
```tsx
|
||||
// BAD
|
||||
type Severity = "info" | "warning" | "error";
|
||||
|
||||
// GOOD
|
||||
export const Severities = ["info", "warning", "error"] as const;
|
||||
export type Severity = (typeof Severities)[number];
|
||||
```
|
||||
|
||||
Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
|
||||
|
||||
---
|
||||
|
||||
## Pre-PR checklist
|
||||
|
||||
Before you open a PR, scan your diff for these:
|
||||
|
||||
- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1)
|
||||
- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2)
|
||||
- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3)
|
||||
- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4)
|
||||
- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5)
|
||||
- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions)
|
||||
|
||||
A clean PR on these axes is a PR that ships without a normalize follow-up.
|
||||
@@ -13,6 +13,7 @@ const config: Config = {
|
||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||
},
|
||||
testMatch: ["<rootDir>/test/**/*.test.ts"],
|
||||
setupFilesAfterEnv: [],
|
||||
|
||||
@@ -64,8 +64,10 @@ 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";
|
||||
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
|
||||
|
||||
// Third-party
|
||||
import { Resolver } from "dns/promises";
|
||||
import axios from "axios";
|
||||
import got from "got";
|
||||
import ping from "ping";
|
||||
@@ -226,6 +228,7 @@ export const initializeServices = async ({
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||
|
||||
const networkService = new NetworkService(axios, logger, [
|
||||
pingProvider,
|
||||
@@ -237,6 +240,7 @@ export const initializeServices = async ({
|
||||
gameProvider,
|
||||
grpcProvider,
|
||||
webSocketProvider,
|
||||
dnsProvider,
|
||||
]);
|
||||
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
getUptimeDetailsByIdParamValidation,
|
||||
getUptimeDetailsByIdQueryValidation,
|
||||
importMonitorsBodyValidation,
|
||||
bulkPauseMonitorBodyValidation,
|
||||
} from "@/validation/monitorValidation.js";
|
||||
import sslChecker from "ssl-checker";
|
||||
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { IMonitorService, INotificationsService } from "@/service/index.js";
|
||||
import { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
const SERVICE_NAME = "monitorController";
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface IMonitorController {
|
||||
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
@@ -307,6 +308,33 @@ class MonitorController implements IMonitorController {
|
||||
}
|
||||
};
|
||||
|
||||
bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body);
|
||||
|
||||
const { monitorIds, pause } = validatedBody;
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause });
|
||||
|
||||
const action = pause ? "paused" : "resumed";
|
||||
const monitorStr = monitors.length === 1 ? "monitor" : "monitors";
|
||||
|
||||
let msg = `${monitors.length} ${monitorStr} ${action} successfully`;
|
||||
if (failedCount > 0) {
|
||||
msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`;
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg,
|
||||
data: monitors,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = requireUserId(req.user?.id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema, model, type Types } from "mongoose";
|
||||
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
|
||||
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
|
||||
|
||||
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
|
||||
},
|
||||
durationUnit: {
|
||||
type: String,
|
||||
enum: ["seconds", "minutes", "hours", "days"],
|
||||
enum: DurationUnits,
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema, model, Types } from "mongoose";
|
||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
|
||||
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
||||
import { DnsRecordTypes, MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
|
||||
import type {
|
||||
CheckAudits,
|
||||
CheckCaptureInfo,
|
||||
@@ -351,6 +351,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
type: Number,
|
||||
default: 300000,
|
||||
},
|
||||
dnsServer: {
|
||||
type: String,
|
||||
},
|
||||
dnsRecordType: {
|
||||
type: String,
|
||||
enum: DnsRecordTypes,
|
||||
},
|
||||
recentChecks: {
|
||||
type: [checkSnapshotSchema],
|
||||
default: [],
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface IMonitorsRepository {
|
||||
statusPatch?: Partial<Monitor>
|
||||
): Promise<Monitor>;
|
||||
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
|
||||
// delete
|
||||
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
|
||||
|
||||
@@ -234,6 +234,34 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return this.toEntity(monitor);
|
||||
};
|
||||
|
||||
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
|
||||
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
|
||||
const filter = {
|
||||
_id: { $in: objectIds },
|
||||
teamId: new mongoose.Types.ObjectId(teamId),
|
||||
isActive: pause, // Only pause if active (true), only resume if inactive (false)
|
||||
};
|
||||
const nextStatus = pause ? "paused" : "initializing";
|
||||
|
||||
const eligible = await MonitorModel.find(filter);
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
await MonitorModel.updateMany(
|
||||
{ _id: { $in: eligible.map((doc) => doc._id) } },
|
||||
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
|
||||
{ timestamps: false }
|
||||
);
|
||||
|
||||
eligible.forEach((doc) => {
|
||||
doc.isActive = !pause;
|
||||
doc.status = nextStatus;
|
||||
doc.updatedAt = now;
|
||||
});
|
||||
|
||||
return this.mapDocuments(eligible);
|
||||
};
|
||||
|
||||
deleteById = async (monitorId: string, teamId: string) => {
|
||||
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
|
||||
|
||||
@@ -418,6 +446,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||
dnsServer: doc.dnsServer ?? undefined,
|
||||
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
@@ -477,6 +507,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||
dnsServer: doc.dnsServer ?? undefined,
|
||||
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ class MonitorRoutes {
|
||||
|
||||
// General monitor routes
|
||||
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
|
||||
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
|
||||
|
||||
// Util routes
|
||||
this.router.get("/certificate/:monitorId", (req, res, next) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface IMonitorService {
|
||||
// update
|
||||
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
|
||||
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
|
||||
|
||||
// delete
|
||||
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
@@ -462,6 +463,43 @@ export class MonitorService implements IMonitorService {
|
||||
return monitor;
|
||||
};
|
||||
|
||||
bulkPauseMonitors = async ({
|
||||
teamId,
|
||||
monitorIds,
|
||||
pause,
|
||||
}: {
|
||||
teamId: string;
|
||||
monitorIds: string[];
|
||||
pause: boolean;
|
||||
}): Promise<{ monitors: Monitor[]; failedCount: number }> => {
|
||||
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
monitors.map(async (monitor) => {
|
||||
if (monitor.isActive) {
|
||||
await this.jobQueue.resumeJob(monitor);
|
||||
} else {
|
||||
await this.jobQueue.pauseJob(monitor);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let failedCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
failedCount++;
|
||||
this.logger.error({
|
||||
message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "bulkPauseMonitors",
|
||||
stack: result.reason instanceof Error ? result.reason.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { monitors, failedCount };
|
||||
};
|
||||
|
||||
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
|
||||
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||
|
||||
@@ -28,6 +28,8 @@ type QueueJobSummary = {
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
@@ -37,6 +39,7 @@ type QueueJobSummary = {
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
};
|
||||
|
||||
export interface ISuperSimpleQueue {
|
||||
@@ -223,8 +226,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
if (result === false) {
|
||||
throw new Error("Failed to pause monitor");
|
||||
}
|
||||
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||
|
||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||
if (geoJob) {
|
||||
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
|
||||
if (geoRes === false) {
|
||||
this.logger.error({
|
||||
message: `Failed to pause geo check job for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "pauseJob",
|
||||
});
|
||||
}
|
||||
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
message: `Paused monitor ${monitor.id}`,
|
||||
@@ -238,9 +253,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
if (result === false) {
|
||||
throw new Error("Failed to resume monitor");
|
||||
}
|
||||
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||
|
||||
await this.scheduler.resumeJob(`${monitor.id}-geo`);
|
||||
|
||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||
if (geoJob) {
|
||||
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
|
||||
if (geoRes === false) {
|
||||
this.logger.error({
|
||||
message: `Failed to resume geo check job for monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "resumeJob",
|
||||
});
|
||||
}
|
||||
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
|
||||
}
|
||||
this.logger.debug({
|
||||
message: `Resumed monitor ${monitor.id}`,
|
||||
service: SERVICE_NAME,
|
||||
@@ -341,7 +367,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
||||
monitorUrl: job.data?.url || null,
|
||||
monitorType: job.data?.type || null,
|
||||
monitorInterval: job.data?.interval || null,
|
||||
monitorGeoInterval: job.data?.geoCheckInterval || null,
|
||||
monitorActive: job.data?.isActive ?? null,
|
||||
active: job.active,
|
||||
repeat: job.repeat ?? null,
|
||||
lockedAt: job.lockedAt ?? null,
|
||||
runCount: job.runCount ?? 0,
|
||||
failCount: job.failCount ?? 0,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
|
||||
import type { Resolver } from "dns/promises";
|
||||
import { Monitor, MonitorType } from "@/types/monitor.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
|
||||
|
||||
const SERVICE_NAME = "DNSProvider";
|
||||
|
||||
export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
|
||||
readonly type = "dns";
|
||||
constructor(private createResolver: () => Resolver) {}
|
||||
supports(type: MonitorType) {
|
||||
return type === "dns";
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
|
||||
try {
|
||||
const { url: hostname, dnsServer, dnsRecordType = "A" } = monitor;
|
||||
if (!dnsServer) {
|
||||
throw new Error("DNS server is required for DNS monitoring");
|
||||
}
|
||||
|
||||
const resolver = this.createResolver();
|
||||
resolver.setServers([dnsServer]);
|
||||
|
||||
const {
|
||||
response: results,
|
||||
responseTime,
|
||||
error,
|
||||
} = await timeRequest(() => {
|
||||
switch (dnsRecordType.toUpperCase()) {
|
||||
case "A":
|
||||
return resolver.resolve4(hostname);
|
||||
case "AAAA":
|
||||
return resolver.resolve6(hostname);
|
||||
case "CNAME":
|
||||
return resolver.resolveCname(hostname);
|
||||
case "MX":
|
||||
return resolver.resolveMx(hostname);
|
||||
case "TXT":
|
||||
return resolver.resolveTxt(hostname);
|
||||
case "NS":
|
||||
return resolver.resolveNs(hostname);
|
||||
default:
|
||||
return resolver.resolve(hostname, dnsRecordType);
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
responseTime,
|
||||
payload: {
|
||||
hostname,
|
||||
dnsServer,
|
||||
recordType: dnsRecordType,
|
||||
resolved: false,
|
||||
results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
responseTime,
|
||||
payload: {
|
||||
hostname,
|
||||
dnsServer,
|
||||
recordType: dnsRecordType,
|
||||
resolved: true,
|
||||
results,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const originalMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new AppError({
|
||||
message: originalMessage || "Error performing DNS check",
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method: "handle",
|
||||
details: { url: monitor.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const;
|
||||
export type DurationUnit = (typeof DurationUnits)[number];
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js";
|
||||
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
|
||||
export type { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
|
||||
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
@@ -14,6 +14,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
||||
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
export const MAX_RECENT_CHECKS = 50;
|
||||
|
||||
export interface Monitor {
|
||||
@@ -54,6 +58,8 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CheckNetworkInterfaceInfo,
|
||||
GotTimings,
|
||||
ILighthouseAudit,
|
||||
DnsRecordType,
|
||||
Monitor,
|
||||
MonitorMatchMethod,
|
||||
MonitorType,
|
||||
@@ -116,6 +117,14 @@ export interface WebSocketStatusPayload {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface DNSStatusPayload {
|
||||
hostname: string;
|
||||
dnsServer: string;
|
||||
recordType: DnsRecordType;
|
||||
resolved: boolean;
|
||||
results: unknown;
|
||||
}
|
||||
|
||||
export interface MonitorPayloadMap {
|
||||
ping: PingStatusPayload;
|
||||
http: HttpStatusPayload;
|
||||
@@ -126,6 +135,7 @@ export interface MonitorPayloadMap {
|
||||
game: GameStatusPayload;
|
||||
grpc: GrpcStatusPayload;
|
||||
websocket: WebSocketStatusPayload;
|
||||
dns: DNSStatusPayload;
|
||||
unknown: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { DurationUnits } from "@/types/maintenanceWindow.js";
|
||||
|
||||
const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
||||
|
||||
@@ -7,17 +8,37 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString());
|
||||
// Maintenance Window Validations
|
||||
//****************************************
|
||||
|
||||
export const createMaintenanceWindowBodyValidation = z.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
expiry: dateToString.optional(),
|
||||
});
|
||||
export const createMaintenanceWindowBodyValidation = z
|
||||
.object({
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
active: z.boolean().optional(),
|
||||
duration: z.number().min(1, "Duration is required"),
|
||||
durationUnit: z.enum(DurationUnits),
|
||||
start: dateToString,
|
||||
end: dateToString,
|
||||
repeat: z.number().min(0, "Repeat must be a non-negative number"),
|
||||
expiry: dateToString.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
const start = new Date(data.start).getTime();
|
||||
const end = new Date(data.end).getTime();
|
||||
if (end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
if (data.repeat === 0 && end <= Date.now()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const getMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
@@ -43,14 +64,29 @@ export const editMaintenanceWindowByIdParamValidation = z.object({
|
||||
id: z.string().min(1, "ID is required"),
|
||||
});
|
||||
|
||||
export const editMaintenanceByIdWindowBodyValidation = z.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
expiry: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
|
||||
});
|
||||
export const editMaintenanceByIdWindowBodyValidation = z
|
||||
.object({
|
||||
active: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
repeat: z.number().optional(),
|
||||
start: dateToString.optional(),
|
||||
end: dateToString.optional(),
|
||||
expiry: dateToString.optional(),
|
||||
monitors: z.array(z.string()).optional(),
|
||||
duration: z.number().optional(),
|
||||
durationUnit: z.enum(DurationUnits).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.start && data.end) {
|
||||
const start = new Date(data.start).getTime();
|
||||
const end = new Date(data.end).getTime();
|
||||
if (end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be after start",
|
||||
path: ["end"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { GeoContinents } from "@/types/geoCheck.js";
|
||||
import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
||||
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
|
||||
|
||||
export const getMonitorByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
@@ -20,12 +20,7 @@ export const getMonitorByIdQueryValidation = z.object({
|
||||
export const getMonitorsByTeamIdParamValidation = z.object({});
|
||||
|
||||
export const getMonitorsByTeamIdQueryValidation = z.object({
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||
filter: z.union([z.string(), z.literal("")]).optional(),
|
||||
});
|
||||
|
||||
@@ -36,12 +31,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({
|
||||
filter: z.union([z.string(), z.literal("")]).optional(),
|
||||
field: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
type: z
|
||||
.union([
|
||||
z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]),
|
||||
z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])),
|
||||
])
|
||||
.optional(),
|
||||
type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(),
|
||||
explain: booleanCoercion.optional(),
|
||||
});
|
||||
|
||||
@@ -78,6 +68,8 @@ export const createMonitorBodyValidation = z.object({
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
});
|
||||
|
||||
export const editMonitorBodyValidation = z.object({
|
||||
@@ -107,12 +99,22 @@ export const editMonitorBodyValidation = z.object({
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
});
|
||||
|
||||
export const pauseMonitorParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
|
||||
export const bulkPauseMonitorBodyValidation = z.object({
|
||||
monitorIds: z
|
||||
.array(z.string().min(1, "Monitor ID must not be empty"))
|
||||
.min(1, "At least one monitor ID is required")
|
||||
.max(100, "Cannot bulk update more than 100 monitors at once"),
|
||||
pause: z.boolean(),
|
||||
});
|
||||
|
||||
export const getUptimeDetailsByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
@@ -160,6 +162,8 @@ const importedMonitorSchema = z.object({
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
createdAt: z.string().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
@@ -211,6 +215,8 @@ export const monitorResponseSchema = z
|
||||
geoCheckEnabled: z.boolean(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||
geoCheckInterval: z.number(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
createdAt: z.string(),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts";
|
||||
import type { Monitor } from "../../../../src/types/index.ts";
|
||||
import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts";
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResolverInstance = {
|
||||
setServers: jest.fn(),
|
||||
resolve4: jest.fn<() => Promise<string[]>>(),
|
||||
resolve6: jest.fn<() => Promise<string[]>>(),
|
||||
resolveCname: jest.fn<() => Promise<string[]>>(),
|
||||
resolveMx: jest.fn<() => Promise<unknown[]>>(),
|
||||
resolveTxt: jest.fn<() => Promise<string[][]>>(),
|
||||
resolveNs: jest.fn<() => Promise<string[]>>(),
|
||||
resolve: jest.fn<() => Promise<unknown>>(),
|
||||
};
|
||||
|
||||
const createResolver = () => mockResolverInstance as any;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
url: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "A",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolverInstance.setServers.mockClear();
|
||||
mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]);
|
||||
mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]);
|
||||
mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]);
|
||||
mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]);
|
||||
mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]);
|
||||
mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]);
|
||||
mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]);
|
||||
});
|
||||
|
||||
// ── Contract ─────────────────────────────────────────────────────────────────
|
||||
|
||||
testStatusProviderContract("DNSProvider", {
|
||||
create: () => new DNSProvider(createResolver),
|
||||
supportedType: "dns",
|
||||
unsupportedType: "ping",
|
||||
makeMonitor: () => makeMonitor(),
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DNSProvider", () => {
|
||||
it("returns success with results and payload on resolve", async () => {
|
||||
mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]);
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "Success",
|
||||
payload: {
|
||||
hostname: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
recordType: "A",
|
||||
resolved: true,
|
||||
results: ["1.2.3.4"],
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(typeof result.responseTime).toBe("number");
|
||||
});
|
||||
|
||||
it("calls setServers with the configured dnsServer", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" }));
|
||||
expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("defaults to A record when dnsRecordType is not provided", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
const result = await provider.handle(makeMonitor({ dnsRecordType: undefined }));
|
||||
expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com");
|
||||
expect(result.payload?.recordType).toBe("A");
|
||||
});
|
||||
|
||||
it("dispatches AAAA records to resolve6", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "AAAA" }));
|
||||
expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches CNAME records to resolveCname", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "CNAME" }));
|
||||
expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches MX records to resolveMx", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "MX" }));
|
||||
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches TXT records to resolveTxt", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "TXT" }));
|
||||
expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("dispatches NS records to resolveNs", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "NS" }));
|
||||
expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("falls back to generic resolve for unrecognized record types", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "SOA" }));
|
||||
expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA");
|
||||
});
|
||||
|
||||
it("treats record type case-insensitively", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
await provider.handle(makeMonitor({ dnsRecordType: "mx" }));
|
||||
expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com");
|
||||
});
|
||||
|
||||
it("returns failure response with NETWORK_ERROR when resolver rejects", async () => {
|
||||
mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "dns",
|
||||
status: false,
|
||||
code: NETWORK_ERROR,
|
||||
message: "ENOTFOUND",
|
||||
payload: expect.objectContaining({
|
||||
hostname: "example.com",
|
||||
dnsServer: "8.8.8.8",
|
||||
recordType: "A",
|
||||
resolved: false,
|
||||
results: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(typeof result.responseTime).toBe("number");
|
||||
});
|
||||
|
||||
it("stringifies non-Error rejections in failure response", async () => {
|
||||
mockResolverInstance.resolve4.mockRejectedValue("DNS bombed");
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("DNS bombed");
|
||||
});
|
||||
|
||||
it("throws AppError when dnsServer is missing", async () => {
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring");
|
||||
});
|
||||
|
||||
it("falls back to default message when underlying error has empty message", async () => {
|
||||
mockResolverInstance.setServers.mockImplementationOnce(() => {
|
||||
throw new Error("");
|
||||
});
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check");
|
||||
});
|
||||
|
||||
it("stringifies non-Error throws from outside timeRequest", async () => {
|
||||
mockResolverInstance.setServers.mockImplementationOnce(() => {
|
||||
throw 42;
|
||||
});
|
||||
const provider = new DNSProvider(createResolver);
|
||||
|
||||
await expect(provider.handle(makeMonitor())).rejects.toThrow("42");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ const createMonitorsRepositoryMock = () =>
|
||||
updateById: jest.fn(),
|
||||
updateNotifications: jest.fn(),
|
||||
togglePauseById: jest.fn(),
|
||||
bulkTogglePause: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
deleteByTeamId: jest.fn(),
|
||||
}) as unknown as IMonitorsRepository;
|
||||
@@ -839,6 +840,141 @@ describe("MonitorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkPauseMonitors", () => {
|
||||
it("resumes jobs for monitors that are active after bulk toggle", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })];
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: false,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(2);
|
||||
expect(result.failedCount).toBe(0);
|
||||
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2);
|
||||
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pauses jobs for monitors that are inactive after bulk toggle", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })];
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(2);
|
||||
expect(result.failedCount).toBe(0);
|
||||
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2);
|
||||
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles mixed active/inactive monitors correctly", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const activeMonitor = makeMonitor({ id: "m1", isActive: true });
|
||||
const pausedMonitor = makeMonitor({ id: "m2", isActive: false });
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(2);
|
||||
expect(result.failedCount).toBe(0);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor);
|
||||
});
|
||||
|
||||
it("returns empty array when no monitors were affected", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(0);
|
||||
expect(result.failedCount).toBe(0);
|
||||
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
|
||||
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works with a single monitor", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor({ id: "m1", isActive: false });
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(1);
|
||||
expect(result.failedCount).toBe(0);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("returns failedCount and logs errors when multiple job operations reject", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitors = [
|
||||
makeMonitor({ id: "m1", isActive: false }),
|
||||
makeMonitor({ id: "m2", isActive: false }),
|
||||
makeMonitor({ id: "m3", isActive: false }),
|
||||
];
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
|
||||
const jobQueue = createJobQueueMock();
|
||||
(jobQueue.pauseJob as jest.Mock)
|
||||
.mockRejectedValueOnce(new Error("Queue failure 1"))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("Queue failure 2"));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const { service } = createService({ monitorsRepository, jobQueue, logger });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2", "m3"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result.monitors).toHaveLength(3);
|
||||
expect(result.failedCount).toBe(2);
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Failed to sync job queue for monitor m1"),
|
||||
method: "bulkPauseMonitors",
|
||||
})
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Failed to sync job queue for monitor m3"),
|
||||
method: "bulkPauseMonitors",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMonitor", () => {
|
||||
it("deletes monitor and all associated data", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
|
||||
@@ -291,34 +291,71 @@ describe("SuperSimpleQueue", () => {
|
||||
// ── pauseJob ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("pauseJob", () => {
|
||||
it("pauses monitor and removes geo job if it exists", async () => {
|
||||
it("pauses monitor and pauses geo job when geo exists", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.pauseJob(makeMonitor());
|
||||
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
|
||||
});
|
||||
|
||||
it("does not remove geo job if it does not exist", async () => {
|
||||
it("refreshes scheduler data with current monitor on pause", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.pauseJob(makeMonitor());
|
||||
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
const monitor = makeMonitor({ isActive: false, status: "paused" });
|
||||
|
||||
await queue.pauseJob(monitor);
|
||||
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor });
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor });
|
||||
});
|
||||
|
||||
it("throws when scheduler.pauseJob returns false", async () => {
|
||||
it("does not pause geo job when it does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.pauseJob(makeMonitor());
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("does not refresh geo data when geo job does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.pauseJob(makeMonitor());
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() }));
|
||||
expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything());
|
||||
});
|
||||
|
||||
it("throws when scheduler.pauseJob returns false for the main job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
|
||||
});
|
||||
|
||||
it("does not refresh data when main pauseJob throws", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow();
|
||||
expect(scheduler.updateJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error but does not throw when geo pauseJob returns false", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
(scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
|
||||
|
||||
await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── resumeJob ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resumeJob", () => {
|
||||
it("resumes monitor and geo job", async () => {
|
||||
it("resumes monitor and geo job when geo exists", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
await queue.resumeJob(makeMonitor());
|
||||
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
|
||||
@@ -326,11 +363,64 @@ describe("SuperSimpleQueue", () => {
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
|
||||
});
|
||||
|
||||
it("throws when scheduler.resumeJob returns false", async () => {
|
||||
it("refreshes scheduler data with current monitor on resume", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
const monitor = makeMonitor({ isActive: true, status: "initializing" });
|
||||
|
||||
await queue.resumeJob(monitor);
|
||||
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor });
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor });
|
||||
});
|
||||
|
||||
it("does not resume geo job when it does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.resumeJob(makeMonitor());
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
|
||||
expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
|
||||
it("does not refresh geo data when geo job does not exist", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
await queue.resumeJob(makeMonitor());
|
||||
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() }));
|
||||
expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything());
|
||||
});
|
||||
|
||||
it("throws when scheduler.resumeJob returns false for the main job", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
|
||||
});
|
||||
|
||||
it("does not refresh data when main resumeJob throws", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
|
||||
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow();
|
||||
expect(scheduler.updateJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error but does not throw when geo resumeJob returns false", async () => {
|
||||
const { queue, scheduler, logger } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
(scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
|
||||
|
||||
await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" }));
|
||||
});
|
||||
|
||||
it("preserves geo job through a pause/resume cycle", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
|
||||
|
||||
await queue.pauseJob(makeMonitor());
|
||||
await queue.resumeJob(makeMonitor());
|
||||
|
||||
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateJob ────────────────────────────────────────────────────────────
|
||||
@@ -467,6 +557,7 @@ describe("SuperSimpleQueue", () => {
|
||||
{
|
||||
id: "m1",
|
||||
active: true,
|
||||
repeat: 60000,
|
||||
lockedAt: null,
|
||||
runCount: 5,
|
||||
failCount: 1,
|
||||
@@ -474,7 +565,7 @@ describe("SuperSimpleQueue", () => {
|
||||
lastRunAt: 1000,
|
||||
lastFinishedAt: 1100,
|
||||
lastFailedAt: 900,
|
||||
data: { url: "http://a.com", type: "http", interval: 60000 },
|
||||
data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true },
|
||||
},
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
@@ -485,7 +576,10 @@ describe("SuperSimpleQueue", () => {
|
||||
monitorUrl: "http://a.com",
|
||||
monitorType: "http",
|
||||
monitorInterval: 60000,
|
||||
monitorGeoInterval: 300000,
|
||||
monitorActive: true,
|
||||
active: true,
|
||||
repeat: 60000,
|
||||
lockedAt: null,
|
||||
runCount: 5,
|
||||
failCount: 1,
|
||||
@@ -513,6 +607,30 @@ describe("SuperSimpleQueue", () => {
|
||||
expect(jobs[0].monitorUrl).toBeNull();
|
||||
expect(jobs[0].monitorType).toBeNull();
|
||||
expect(jobs[0].monitorInterval).toBeNull();
|
||||
expect(jobs[0].monitorGeoInterval).toBeNull();
|
||||
expect(jobs[0].monitorActive).toBeNull();
|
||||
expect(jobs[0].repeat).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes monitor.isActive as monitorActive in the summary", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } },
|
||||
{ id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } },
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
expect(jobs[0].monitorActive).toBe(true);
|
||||
expect(jobs[1].monitorActive).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes geo check interval and repeat", async () => {
|
||||
const { queue, scheduler } = createQueue();
|
||||
(scheduler.getJobs as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } },
|
||||
]);
|
||||
const jobs = await queue.getJobs();
|
||||
expect(jobs[0].repeat).toBe(120000);
|
||||
expect(jobs[0].monitorGeoInterval).toBe(600000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, jest, afterEach } from "@jest/globals";
|
||||
import {
|
||||
createMaintenanceWindowBodyValidation,
|
||||
editMaintenanceByIdWindowBodyValidation,
|
||||
} from "../../../src/validation/maintenanceWindowValidation.ts";
|
||||
|
||||
const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime();
|
||||
|
||||
const baseCreateBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
monitors: ["mon-1"],
|
||||
name: "Scheduled Maintenance",
|
||||
duration: 60,
|
||||
durationUnit: "minutes",
|
||||
start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
|
||||
repeat: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createMaintenanceWindowBodyValidation", () => {
|
||||
it("accepts a valid one-time window with end in the future", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a recurring window even when end is in the past", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
repeat: 1,
|
||||
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when end is before start", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when end equals start", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString();
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant }));
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a one-time window with end in the past", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a one-time window with end exactly equal to now", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(
|
||||
baseCreateBody({
|
||||
start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(),
|
||||
end: new Date(FROZEN_NOW).toISOString(),
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "End must be in the future for one-time maintenance windows",
|
||||
path: ["end"],
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown fields under .strict()", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid durationUnit value", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" }));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing required field", () => {
|
||||
jest.useFakeTimers().setSystemTime(FROZEN_NOW);
|
||||
const { name: _name, ...withoutName } = baseCreateBody();
|
||||
const result = createMaintenanceWindowBodyValidation.safeParse(withoutName);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMaintenanceByIdWindowBodyValidation", () => {
|
||||
it("accepts an empty body (all fields optional)", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a partial update with only active toggled", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown fields under .strict()", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
active: false,
|
||||
id: "mw-1",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when start and end are both present and end <= start", () => {
|
||||
const start = new Date("2026-05-08T10:00:00Z").toISOString();
|
||||
const end = new Date("2026-05-08T09:00:00Z").toISOString();
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })]));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply the start/end ordering rule when only end is provided", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
end: new Date("2020-01-01T00:00:00Z").toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("does not apply the start/end ordering rule when only start is provided", () => {
|
||||
const result = editMaintenanceByIdWindowBodyValidation.safeParse({
|
||||
start: new Date("2099-01-01T00:00:00Z").toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts";
|
||||
|
||||
const baseDnsBody = {
|
||||
name: "DNS check",
|
||||
type: "dns" as const,
|
||||
url: "example.com",
|
||||
};
|
||||
|
||||
describe("monitorValidation — DNS fields", () => {
|
||||
describe("createMonitorBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on a DNS monitor", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "A",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBe("8.8.8.8");
|
||||
expect(parsed.dnsRecordType).toBe("A");
|
||||
});
|
||||
|
||||
it("accepts every supported DNS record type", () => {
|
||||
for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "1.1.1.1",
|
||||
dnsRecordType: recordType,
|
||||
});
|
||||
expect(parsed.dnsRecordType).toBe(recordType);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown DNS record types", () => {
|
||||
expect(() =>
|
||||
createMonitorBodyValidation.parse({
|
||||
...baseDnsBody,
|
||||
dnsServer: "8.8.8.8",
|
||||
dnsRecordType: "BOGUS",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("treats DNS fields as optional (other monitor types still validate)", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
name: "HTTP check",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBeUndefined();
|
||||
expect(parsed.dnsRecordType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMonitorBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on edits", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({
|
||||
dnsServer: "1.1.1.1",
|
||||
dnsRecordType: "MX",
|
||||
});
|
||||
|
||||
expect(parsed.dnsServer).toBe("1.1.1.1");
|
||||
expect(parsed.dnsRecordType).toBe("MX");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importMonitorsBodyValidation", () => {
|
||||
it("retains dnsServer and dnsRecordType on imported DNS monitors", () => {
|
||||
const parsed = importMonitorsBodyValidation.parse({
|
||||
monitors: [
|
||||
{
|
||||
name: "Imported DNS",
|
||||
type: "dns",
|
||||
url: "example.com",
|
||||
dnsServer: "8.8.4.4",
|
||||
dnsRecordType: "TXT",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4");
|
||||
expect(parsed.monitors[0].dnsRecordType).toBe("TXT");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user