From 27d37ece7decd358cca955af8ab02c03bd2cb657 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sun, 12 Oct 2025 21:11:20 -0400 Subject: [PATCH 1/3] feat: Add monitor grouping functionality to backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional 'group' field to Monitor schema with trimming and validation - Add group validation to Joi schemas for create and edit monitor endpoints - Add GET /api/v1/monitors/team/groups endpoint to fetch unique groups - Implement case-insensitive group filtering in database layer - Support for organizing monitors into collapsible groups 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- server/src/controllers/v1/monitorController.js | 18 ++++++++++++++++++ server/src/db/v1/models/Monitor.js | 9 +++++++++ server/src/db/v1/modules/monitorModule.js | 15 +++++++++++++++ server/src/routes/v1/monitorRoute.js | 1 + .../src/service/v1/business/monitorService.js | 5 +++++ server/src/validation/joi.js | 2 ++ 6 files changed, 50 insertions(+) diff --git a/server/src/controllers/v1/monitorController.js b/server/src/controllers/v1/monitorController.js index 462d5a278..5ac44fa94 100644 --- a/server/src/controllers/v1/monitorController.js +++ b/server/src/controllers/v1/monitorController.js @@ -452,6 +452,24 @@ class MonitorController extends BaseController { SERVICE_NAME, "getAllGames" ); + + getGroupsByTeamId = this.asyncHandler( + async (req, res) => { + const teamId = req?.user?.teamId; + if (!teamId) { + throw this.errorService.createBadRequestError("Team ID is required"); + } + + const groups = await this.monitorService.getGroupsByTeamId({ teamId }); + + return res.success({ + msg: "OK", + data: groups, + }); + }, + SERVICE_NAME, + "getGroupsByTeamId" + ); } export default MonitorController; diff --git a/server/src/db/v1/models/Monitor.js b/server/src/db/v1/models/Monitor.js index 0e12d3c26..7d499dcce 100755 --- a/server/src/db/v1/models/Monitor.js +++ b/server/src/db/v1/models/Monitor.js @@ -128,6 +128,15 @@ const MonitorSchema = mongoose.Schema( gameId: { type: String, }, + group: { + type: String, + trim: true, + maxLength: 50, + default: null, + set: function(value) { + return value && value.trim() ? value.trim() : null; + } + }, }, { timestamps: true, diff --git a/server/src/db/v1/modules/monitorModule.js b/server/src/db/v1/modules/monitorModule.js index f795a7f2e..83aa29dfe 100755 --- a/server/src/db/v1/modules/monitorModule.js +++ b/server/src/db/v1/modules/monitorModule.js @@ -573,6 +573,21 @@ class MonitorModule { throw error; } }; + + getGroupsByTeamId = async ({ teamId }) => { + try { + const groups = await this.Monitor.distinct("group", { + teamId: new this.ObjectId(teamId), + group: { $ne: null, $ne: "" } + }); + + return groups.filter(Boolean).sort(); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getGroupsByTeamId"; + throw error; + } + }; } export default MonitorModule; diff --git a/server/src/routes/v1/monitorRoute.js b/server/src/routes/v1/monitorRoute.js index 107964f6b..dc0dd694b 100755 --- a/server/src/routes/v1/monitorRoute.js +++ b/server/src/routes/v1/monitorRoute.js @@ -18,6 +18,7 @@ class MonitorRoutes { this.router.get("/team", this.monitorController.getMonitorsByTeamId); this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); this.router.get("/team/summary", this.monitorController.getMonitorsAndSummaryByTeamId); + this.router.get("/team/groups", this.monitorController.getGroupsByTeamId); // Uptime routes this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById); diff --git a/server/src/service/v1/business/monitorService.js b/server/src/service/v1/business/monitorService.js index 061234102..2b643ee64 100644 --- a/server/src/service/v1/business/monitorService.js +++ b/server/src/service/v1/business/monitorService.js @@ -267,6 +267,11 @@ class MonitorService { getAllGames = () => { return this.games; }; + + getGroupsByTeamId = async ({ teamId }) => { + const groups = await this.db.monitorModule.getGroupsByTeamId({ teamId }); + return groups; + }; } export default MonitorService; diff --git a/server/src/validation/joi.js b/server/src/validation/joi.js index 1cd5cd5d6..87d2b2a8c 100755 --- a/server/src/validation/joi.js +++ b/server/src/validation/joi.js @@ -174,6 +174,7 @@ const createMonitorBodyValidation = joi.object({ expectedValue: joi.string().allow(""), matchMethod: joi.string(), gameId: joi.string().allow(""), + group: joi.string().max(50).trim().allow(null, "").optional(), }); const createMonitorsBodyValidation = joi.array().items( @@ -203,6 +204,7 @@ const editMonitorBodyValidation = joi.object({ usage_temperature: joi.number(), }), gameId: joi.string(), + group: joi.string().max(50).trim().allow(null, "").optional(), }); const pauseMonitorParamValidation = joi.object({ From 6bdf9891e898f76ee07e298c3e25aa29c5042a07 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sun, 12 Oct 2025 21:51:06 -0400 Subject: [PATCH 2/3] Fix server formatting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- server/src/db/v1/models/Monitor.js | 4 ++-- server/src/db/v1/modules/monitorModule.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/db/v1/models/Monitor.js b/server/src/db/v1/models/Monitor.js index 7d499dcce..e6e1a0024 100755 --- a/server/src/db/v1/models/Monitor.js +++ b/server/src/db/v1/models/Monitor.js @@ -133,9 +133,9 @@ const MonitorSchema = mongoose.Schema( trim: true, maxLength: 50, default: null, - set: function(value) { + set: function (value) { return value && value.trim() ? value.trim() : null; - } + }, }, }, { diff --git a/server/src/db/v1/modules/monitorModule.js b/server/src/db/v1/modules/monitorModule.js index 83aa29dfe..39168f0b2 100755 --- a/server/src/db/v1/modules/monitorModule.js +++ b/server/src/db/v1/modules/monitorModule.js @@ -578,7 +578,7 @@ class MonitorModule { try { const groups = await this.Monitor.distinct("group", { teamId: new this.ObjectId(teamId), - group: { $ne: null, $ne: "" } + group: { $ne: null, $ne: "" }, }); return groups.filter(Boolean).sort(); From e18e2e26da9ad83b14104c55712d5394342366b7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 14 Oct 2025 12:45:11 -0700 Subject: [PATCH 3/3] move v2 utils to v2 dir --- .../v2/DesignElements/StatusLabel.tsx | 2 +- .../v2/Monitors/ChartAvgResponse.tsx | 2 +- .../v2/Monitors/ChartResponseTime.tsx | 4 +- .../v2/Monitors/HistogramResponseTime.tsx | 2 +- .../Monitors/HistogramResponseTimeTooltip.tsx | 2 +- .../v2/Monitors/HistogramStatus.tsx | 6 +- .../Components/v2/Monitors/MonitorStatus.tsx | 2 +- client/src/Hooks/v2/UseApi.tsx | 2 +- client/src/Hooks/v2/useInitMonitorForm.tsx | 17 ++ client/src/Pages/v2/Uptime/CheckTable.tsx | 2 +- client/src/Pages/v2/Uptime/Configure.tsx | 43 +++ client/src/Pages/v2/Uptime/Details.tsx | 2 +- client/src/Pages/v2/Uptime/UptimeForm.tsx | 247 ++++++++++++++++++ client/src/Utils/{ => v2}/ApiClient.ts | 0 client/src/Utils/{ => v2}/DataUtils.ts | 0 client/src/Utils/{ => v2}/MonitorUtils.ts | 2 +- client/src/Utils/{ => v2}/TimeUtils.ts | 0 17 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 client/src/Hooks/v2/useInitMonitorForm.tsx create mode 100644 client/src/Pages/v2/Uptime/Configure.tsx create mode 100644 client/src/Pages/v2/Uptime/UptimeForm.tsx rename client/src/Utils/{ => v2}/ApiClient.ts (100%) rename client/src/Utils/{ => v2}/DataUtils.ts (100%) rename client/src/Utils/{ => v2}/MonitorUtils.ts (94%) rename client/src/Utils/{ => v2}/TimeUtils.ts (100%) diff --git a/client/src/Components/v2/DesignElements/StatusLabel.tsx b/client/src/Components/v2/DesignElements/StatusLabel.tsx index a314c09b0..e4d288e63 100644 --- a/client/src/Components/v2/DesignElements/StatusLabel.tsx +++ b/client/src/Components/v2/DesignElements/StatusLabel.tsx @@ -2,7 +2,7 @@ import Box from "@mui/material/Box"; import { BaseBox } from "@/Components/v2/DesignElements"; import type { MonitorStatus } from "@/Types/Monitor"; -import { getStatusPalette } from "@/Utils/MonitorUtils"; +import { getStatusPalette } from "@/Utils/v2/MonitorUtils"; import { useTheme } from "@mui/material/styles"; export const StatusLabel = ({ diff --git a/client/src/Components/v2/Monitors/ChartAvgResponse.tsx b/client/src/Components/v2/Monitors/ChartAvgResponse.tsx index 10938f359..e9b85cb43 100644 --- a/client/src/Components/v2/Monitors/ChartAvgResponse.tsx +++ b/client/src/Components/v2/Monitors/ChartAvgResponse.tsx @@ -4,7 +4,7 @@ import Typography from "@mui/material/Typography"; import AverageResponseIcon from "@/assets/icons/average-response-icon.svg?react"; import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts"; -import { getResponseTimeColor } from "@/Utils/MonitorUtils"; +import { getResponseTimeColor } from "@/Utils/v2/MonitorUtils"; import { useTheme } from "@mui/material/styles"; export const ChartAvgResponse = ({ avg, max }: { avg: number; max: number }) => { diff --git a/client/src/Components/v2/Monitors/ChartResponseTime.tsx b/client/src/Components/v2/Monitors/ChartResponseTime.tsx index 28f40f254..3c1c93f01 100644 --- a/client/src/Components/v2/Monitors/ChartResponseTime.tsx +++ b/client/src/Components/v2/Monitors/ChartResponseTime.tsx @@ -1,7 +1,7 @@ import { BaseChart } from "./HistogramStatus"; import { BaseBox } from "../DesignElements"; import ResponseTimeIcon from "@/assets/icons/response-time-icon.svg?react"; -import { normalizeResponseTimes } from "@/Utils/DataUtils"; +import { normalizeResponseTimes } from "@/Utils/v2/DataUtils"; import { AreaChart, Area, @@ -17,7 +17,7 @@ import { formatDateWithTz, tickDateFormatLookup, tooltipDateFormatLookup, -} from "@/Utils/TimeUtils"; +} from "@/Utils/v2/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { GroupedCheck } from "@/Types/Check"; import { useSelector } from "react-redux"; diff --git a/client/src/Components/v2/Monitors/HistogramResponseTime.tsx b/client/src/Components/v2/Monitors/HistogramResponseTime.tsx index 1925ac93c..aa1996e2a 100644 --- a/client/src/Components/v2/Monitors/HistogramResponseTime.tsx +++ b/client/src/Components/v2/Monitors/HistogramResponseTime.tsx @@ -3,7 +3,7 @@ import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; import type { Check } from "@/Types/Check"; import { HistogramResponseTimeTooltip } from "@/Components/v2/Monitors/HistogramResponseTimeTooltip"; -import { normalizeResponseTimes } from "@/Utils/DataUtils"; +import { normalizeResponseTimes } from "@/Utils/v2/DataUtils"; export const HistogramResponseTime = ({ checks }: { checks: Check[] }) => { const normalChecks = normalizeResponseTimes(checks, "responseTime"); diff --git a/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx b/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx index d704cf5d4..d8c0263f0 100644 --- a/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx +++ b/client/src/Components/v2/Monitors/HistogramResponseTimeTooltip.tsx @@ -1,7 +1,7 @@ import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz } from "@/Utils/v2/TimeUtils"; import { useSelector } from "react-redux"; import type { LatestCheck } from "@/Types/Check"; diff --git a/client/src/Components/v2/Monitors/HistogramStatus.tsx b/client/src/Components/v2/Monitors/HistogramStatus.tsx index 2e10a8042..bc6675bc3 100644 --- a/client/src/Components/v2/Monitors/HistogramStatus.tsx +++ b/client/src/Components/v2/Monitors/HistogramStatus.tsx @@ -9,12 +9,12 @@ import IncidentsIcon from "@/assets/icons/incidents.svg?react"; import type { GroupedCheck } from "@/Types/Check"; import type { MonitorStatus } from "@/Types/Monitor"; -import { normalizeResponseTimes } from "@/Utils/DataUtils"; +import { normalizeResponseTimes } from "@/Utils/v2/DataUtils"; import { useState } from "react"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz } from "@/Utils/v2/TimeUtils"; import { useSelector } from "react-redux"; import { useTheme } from "@mui/material/styles"; -import { getResponseTimeColor } from "@/Utils/MonitorUtils"; +import { getResponseTimeColor } from "@/Utils/v2/MonitorUtils"; const XLabel = ({ p1, diff --git a/client/src/Components/v2/Monitors/MonitorStatus.tsx b/client/src/Components/v2/Monitors/MonitorStatus.tsx index ff4d83352..f329e6bb8 100644 --- a/client/src/Components/v2/Monitors/MonitorStatus.tsx +++ b/client/src/Components/v2/Monitors/MonitorStatus.tsx @@ -3,7 +3,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { PulseDot } from "@/Components/v2/DesignElements/PulseDot"; import { Dot } from "@/Components/v2/DesignElements/Dot"; -import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; +import { getStatusColor, formatUrl } from "@/Utils/v2/MonitorUtils"; import { useTheme } from "@mui/material/styles"; import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/v2/palette"; diff --git a/client/src/Hooks/v2/UseApi.tsx b/client/src/Hooks/v2/UseApi.tsx index d3001c87b..2ee37c0da 100644 --- a/client/src/Hooks/v2/UseApi.tsx +++ b/client/src/Hooks/v2/UseApi.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import useSWR from "swr"; import type { SWRConfiguration } from "swr"; import type { AxiosRequestConfig } from "axios"; -import { get, post, patch } from "@/Utils/ApiClient"; // your axios wrapper +import { get, post, patch } from "@/Utils/v2/ApiClient"; // your axios wrapper export type ApiResponse = { message: string; diff --git a/client/src/Hooks/v2/useInitMonitorForm.tsx b/client/src/Hooks/v2/useInitMonitorForm.tsx new file mode 100644 index 000000000..2e98b3753 --- /dev/null +++ b/client/src/Hooks/v2/useInitMonitorForm.tsx @@ -0,0 +1,17 @@ +import { monitorSchema } from "@/Validation/v2/zod"; +import { z } from "zod"; +export const useInitForm = ({ + initialData, +}: { + initialData: Partial> | undefined; +}) => { + const defaults: z.infer = { + type: initialData?.type || "http", + url: initialData?.url || "", + n: initialData?.n || 3, + notificationChannels: initialData?.notificationChannels || [], + name: initialData?.name || "", + interval: initialData?.interval || "1 minute", + }; + return { defaults }; +}; diff --git a/client/src/Pages/v2/Uptime/CheckTable.tsx b/client/src/Pages/v2/Uptime/CheckTable.tsx index 42da6da12..c2811c453 100644 --- a/client/src/Pages/v2/Uptime/CheckTable.tsx +++ b/client/src/Pages/v2/Uptime/CheckTable.tsx @@ -10,7 +10,7 @@ import type { MonitorStatus } from "@/Types/Monitor"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/v2/UseApi"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz } from "@/Utils/v2/TimeUtils"; import { useSelector } from "react-redux"; const getHeaders = (t: Function, uiTimezone: string) => { const headers: Header[] = [ diff --git a/client/src/Pages/v2/Uptime/Configure.tsx b/client/src/Pages/v2/Uptime/Configure.tsx new file mode 100644 index 000000000..dc014669b --- /dev/null +++ b/client/src/Pages/v2/Uptime/Configure.tsx @@ -0,0 +1,43 @@ +import { monitorSchema } from "@/Validation/v2/zod"; +import { useGet, usePost } from "@/Hooks/v2/UseApi"; +import { UptimeForm } from "@/Pages/v2/Uptime/UptimeForm"; + +import { useParams } from "react-router"; +import type { ApiResponse } from "@/Hooks/v2/UseApi"; +import humanInterval from "human-interval"; +import { z } from "zod"; + +export const UptimeConfigurePage = () => { + type FormValues = z.infer; + type SubmitValues = Omit & { interval: number | undefined }; + + const { id } = useParams(); + const { response } = useGet("/notification-channels"); + const { response: monitorResponse } = useGet(`/monitors/${id}`); + const monitor = monitorResponse?.data || null; + const notificationOptions = response?.data ?? []; + + const { post, loading, error } = usePost(); + + const onSubmit = async (data: FormValues) => { + let interval = humanInterval(data.interval); + if (!interval) interval = 60000; + const submitData = { ...data, interval }; + const result = await post("/monitors", submitData); + if (result) { + console.log(result); + } else { + console.error(error); + } + }; + return ( + + ); +}; diff --git a/client/src/Pages/v2/Uptime/Details.tsx b/client/src/Pages/v2/Uptime/Details.tsx index c2069c7e4..5515e7b32 100644 --- a/client/src/Pages/v2/Uptime/Details.tsx +++ b/client/src/Pages/v2/Uptime/Details.tsx @@ -14,7 +14,7 @@ import { useTheme } from "@mui/material/styles"; import { useParams } from "react-router"; import { useGet, usePatch, type ApiResponse } from "@/Hooks/v2/UseApi"; import { useState } from "react"; -import { getStatusPalette } from "@/Utils/MonitorUtils"; +import { getStatusPalette } from "@/Utils/v2/MonitorUtils"; import prettyMilliseconds from "pretty-ms"; const UptimeDetailsPage = () => { diff --git a/client/src/Pages/v2/Uptime/UptimeForm.tsx b/client/src/Pages/v2/Uptime/UptimeForm.tsx new file mode 100644 index 000000000..e9636e5dd --- /dev/null +++ b/client/src/Pages/v2/Uptime/UptimeForm.tsx @@ -0,0 +1,247 @@ +import Stack from "@mui/material/Stack"; +import { TextInput } from "@/Components/v2/Inputs/TextInput"; +import { AutoCompleteInput } from "@/Components/v2/Inputs/AutoComplete"; +import { ConfigBox, BasePage } from "@/Components/v2/DesignElements"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControl from "@mui/material/FormControl"; +import { RadioWithDescription } from "@/Components/v2/Inputs/RadioInput"; +import { Button } from "@/Components/v2/Inputs"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import { Typography } from "@mui/material"; + +import { useTranslation } from "react-i18next"; +import { monitorSchema } from "@/Validation/v2/zod"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, Controller, useWatch, type SubmitHandler } from "react-hook-form"; +import { useTheme } from "@mui/material/styles"; +import { useInitForm } from "@/Hooks/v2/useInitMonitorForm"; + +type FormValues = z.infer; + +export const UptimeForm = ({ + initialData, + onSubmit, + notificationOptions, + loading, +}: { + initialData?: Partial; + onSubmit: SubmitHandler; + notificationOptions: any[]; + loading: boolean; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { defaults } = useInitForm({ initialData: initialData }); + const { + handleSubmit, + control, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(monitorSchema) as any, + defaultValues: defaults, + mode: "onChange", + }); + + const selectedType = useWatch({ + control, + name: "type", + }); + const notificationChannels = useWatch({ + control, + name: "notificationChannels", + }); + + return ( + + ( + + + + + + + + )} + /> + } + /> + + ( + + )} + /> + ( + + )} + /> + + } + /> + ( + { + const target = e.target as HTMLInputElement; + field.onChange(target.valueAsNumber); + }} + /> + )} + /> + } + /> + + ( + option.name} + value={notificationOptions.filter((o: any) => + (field.value || []).includes(o._id) + )} + onChange={(_, newValue) => { + field.onChange(newValue.map((o: any) => o._id)); + }} + /> + )} + /> + + {notificationChannels.map((notificationId) => { + const option = notificationOptions.find( + (o: any) => o._id === notificationId + ); + if (!option) return null; + return ( + + {option.name} + { + const updated = notificationChannels.filter( + (id) => id !== notificationId + ); + setValue("notificationChannels", updated); + }} + sx={{ cursor: "pointer" }} + /> + + ); + })} + + + } + /> + ( + + )} + /> + } + /> + + + + + ); +}; diff --git a/client/src/Utils/ApiClient.ts b/client/src/Utils/v2/ApiClient.ts similarity index 100% rename from client/src/Utils/ApiClient.ts rename to client/src/Utils/v2/ApiClient.ts diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/v2/DataUtils.ts similarity index 100% rename from client/src/Utils/DataUtils.ts rename to client/src/Utils/v2/DataUtils.ts diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/v2/MonitorUtils.ts similarity index 94% rename from client/src/Utils/MonitorUtils.ts rename to client/src/Utils/v2/MonitorUtils.ts index dcd202361..c47b7a82e 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/v2/MonitorUtils.ts @@ -1,5 +1,5 @@ import type { MonitorStatus } from "@/Types/Monitor"; -import type { PaletteKey } from "./Theme/v2/theme"; +import type { PaletteKey } from "@/Utils/Theme/v2/theme"; export const getStatusPalette = (status: MonitorStatus): PaletteKey => { const paletteMap: Record = { up: "success", diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/v2/TimeUtils.ts similarity index 100% rename from client/src/Utils/TimeUtils.ts rename to client/src/Utils/v2/TimeUtils.ts