diff --git a/client/src/components/design-elements/Breadcrumb.tsx b/client/src/components/design-elements/Breadcrumb.tsx index f4986b47e..fa9b228b3 100644 --- a/client/src/components/design-elements/Breadcrumb.tsx +++ b/client/src/components/design-elements/Breadcrumb.tsx @@ -1,6 +1,6 @@ import MuiBreadcrumbs from "@mui/material/Breadcrumbs"; import Typography from "@mui/material/Typography"; -import { Link, useLocation } from "react-router"; +import { Link, useLocation } from "react-router-dom"; import { useTheme } from "@mui/material/styles"; import { ChevronRight, Home } from "lucide-react"; diff --git a/client/src/components/inputs/Select.tsx b/client/src/components/inputs/Select.tsx index f63b03815..f3b56a3a6 100644 --- a/client/src/components/inputs/Select.tsx +++ b/client/src/components/inputs/Select.tsx @@ -10,7 +10,11 @@ interface SelectInputProps extends Omit, "label"> { required?: boolean; } -export const SelectInput = ({ fieldLabel, required, ...props }: SelectInputProps) => { +export const SelectInput = ({ + fieldLabel, + required, + ...props +}: SelectInputProps) => { const theme = useTheme(); const select = ( diff --git a/client/src/components/monitors/HeaderFilter.tsx b/client/src/components/monitors/HeaderFilter.tsx new file mode 100644 index 000000000..139226294 --- /dev/null +++ b/client/src/components/monitors/HeaderFilter.tsx @@ -0,0 +1,95 @@ +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import { Select, Checkbox } from "@/components/inputs"; + +import { useTheme } from "@mui/material/styles"; +import type { SelectChangeEvent } from "@mui/material/Select"; +import { + UptimeMonitorTypes, + MonitorStatuses, + type UptimeMonitorType, + type MonitorStatus, +} from "@/types/monitor"; + +const formatValue = (value: string[], fallback: string) => { + if (value.length === 0) { + return fallback; + } + const capitalized = value.map( + (item) => item.charAt(0).toUpperCase() + item.slice(1) + ); + return capitalized.join(" | "); +}; + +type HeaderFilterProps = { + selectedTypes: UptimeMonitorType[]; + selectedStatuses: MonitorStatus[]; + onTypesChange: (types: UptimeMonitorType[]) => void; + onStatusesChange: (statuses: MonitorStatus[]) => void; +}; + +const coerceValue = (value: string | string[]): T[] => { + if (typeof value === "string") { + return value.split(",") as T[]; + } + return value as T[]; +}; + +export const HeaderFilter = ({ + selectedTypes, + selectedStatuses, + onTypesChange, + onStatusesChange, +}: HeaderFilterProps) => { + const theme = useTheme(); + + const handleTypeChange = (event: SelectChangeEvent) => { + onTypesChange(coerceValue(event.target.value)); + }; + + const handleStatusChange = (event: SelectChangeEvent) => { + onStatusesChange(coerceValue(event.target.value)); + }; + + return ( + + + + + ); +}; + +export default HeaderFilter; diff --git a/client/src/components/monitors/index.tsx b/client/src/components/monitors/index.tsx index 3fec60d2b..30355f7f2 100644 --- a/client/src/components/monitors/index.tsx +++ b/client/src/components/monitors/index.tsx @@ -7,3 +7,5 @@ export { ChartResponseTime } from "./ChartResponseTime"; export { HistogramResponseTime } from "./HistogramResponseTime"; export { HistogramPageSpeed } from "./HistogramPageSpeed"; export { HistogramInfrastructure } from "./HistogramInfrastructure"; + +export { HeaderFilter } from "./HeaderFilter"; diff --git a/client/src/pages/uptime/UptimeMonitors.tsx b/client/src/pages/uptime/UptimeMonitors.tsx index 241506c3a..685c3f6fc 100644 --- a/client/src/pages/uptime/UptimeMonitors.tsx +++ b/client/src/pages/uptime/UptimeMonitors.tsx @@ -5,7 +5,7 @@ import { PausedStatusBox, InfoBox, } from "@/components/design-elements"; -import { HeaderCreate } from "@/components/monitors"; +import { HeaderCreate, HeaderFilter } from "@/components/monitors"; import Stack from "@mui/material/Stack"; import { Dialog } from "@/components/inputs"; import { MonitorTable } from "@/pages/uptime/MonitorTable"; @@ -14,7 +14,14 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useGet } from "@/hooks/UseApi"; import type { ApiResponse } from "@/hooks/UseApi"; -import type { IMonitor, IMonitorWithStats } from "@/types/monitor"; +import { + type IMonitor, + type IMonitorWithStats, + type MonitorStatus, + type UptimeMonitorType, + UptimeMonitorTypes, + MonitorStatuses, +} from "@/types/monitor"; import useMediaQuery from "@mui/material/useMediaQuery"; import { useState } from "react"; import { useDelete } from "@/hooks/UseApi"; @@ -29,12 +36,45 @@ const UptimeMonitors = () => { const isDialogOpen = Boolean(selectedMonitor); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [selectedTypes, setSelectedTypes] = useState([ + "http", + "https", + "ping", + ]); + const [selectedStatuses, setSelectedStatuses] = useState([ + "up", + "down", + "paused", + "initializing", + ]); const { deleteFn } = useDelete(); + + const typeFilter = + selectedTypes.length > 0 ? selectedTypes : UptimeMonitorTypes; + const typeQuery = typeFilter + .map((type: UptimeMonitorType) => `type=${encodeURIComponent(type)}`) + .join("&"); + + const statusFilter = + selectedStatuses.length > 0 ? selectedStatuses : MonitorStatuses; + const statusQuery = statusFilter + .map((status) => `status=${encodeURIComponent(status)}`) + .join("&"); + + const requestParams = [ + "embedChecks=true", + typeQuery, + statusQuery, + `page=${page}`, + `rowsPerPage=${rowsPerPage}`, + ].filter(Boolean); + const monitorRequestUrl = `/monitors?${requestParams.join("&")}`; + const { response, loading, isValidating, error, refetch } = useGet< ApiResponse >( - `/monitors?embedChecks=true&type=http&type=https&type=ping&page=${page}&rowsPerPage=${rowsPerPage}`, + monitorRequestUrl, {}, { refreshInterval: GLOBAL_REFRESH, @@ -62,11 +102,21 @@ const UptimeMonitors = () => { setSelectedMonitor(null); }; + const hasActiveFilters = + selectedTypes.length > 0 || selectedStatuses.length > 0; + const showFallback = monitors.length === 0 && !hasActiveFilters; + + const monitorItems = showFallback + ? [] + : monitors.length > 0 + ? monitors + : [{}]; + return ( @@ -80,6 +130,12 @@ const UptimeMonitors = () => { + =24.0.0" - } - }, "node_modules/@jest/types": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", @@ -4231,20 +4203,6 @@ "node": ">=12" } }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -4272,24 +4230,6 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/server/src/controllers/MonitorController.ts b/server/src/controllers/MonitorController.ts index 256e81c83..73f14a722 100644 --- a/server/src/controllers/MonitorController.ts +++ b/server/src/controllers/MonitorController.ts @@ -79,12 +79,14 @@ class MonitorController { const page = req.validatedQuery.page || 0; const rowsPerPage = req.validatedQuery.rowsPerPage || 10; const type = req.validatedQuery.type; + const status = req.validatedQuery.status; result = await this.monitorService.getAllEmbedChecks( teamId, page, rowsPerPage, - type + type, + status ); } else { result = await this.monitorService.getAll(teamId); diff --git a/server/src/services/business/MonitorService.ts b/server/src/services/business/MonitorService.ts index a080e3392..6260a6df5 100644 --- a/server/src/services/business/MonitorService.ts +++ b/server/src/services/business/MonitorService.ts @@ -6,6 +6,58 @@ import { IJobQueue } from "@/services/infrastructure/JobQueue.js"; import { MonitorWithChecksResponse } from "@/types/index.js"; import { MonitorStatus, MonitorType } from "@/db/models/monitors/Monitor.js"; +const buildStatusCriteria = ( + statuses: MonitorStatus[] +): Record => { + if (!Array.isArray(statuses) || statuses.length === 0) { + return {}; + } + + const uniqueStatuses = Array.from(new Set(statuses)) as MonitorStatus[]; + const includesPaused = uniqueStatuses.includes("paused"); + const activeStatuses = uniqueStatuses.filter((status) => status !== "paused"); + + if (includesPaused && activeStatuses.length > 0) { + return { + $or: [ + { status: { $in: activeStatuses }, isActive: true }, + { isActive: false }, + ], + }; + } + + if (includesPaused) { + return { isActive: false }; + } + + return { + status: { $in: uniqueStatuses }, + isActive: true, + }; +}; + +const buildMatchConditions = ( + teamIdValue: mongoose.Types.ObjectId | string, + types: MonitorType[], + statusCriteria: Record +): Record => { + const conditions: Record = { teamId: teamIdValue }; + + if (Array.isArray(types) && types.length > 0) { + conditions.type = { $in: types }; + } + + if (statusCriteria && Object.keys(statusCriteria).length > 0) { + if ("$or" in statusCriteria) { + conditions.$or = (statusCriteria as { $or: unknown }).$or; + } else { + Object.assign(conditions, statusCriteria); + } + } + + return conditions; +}; + const SERVICE_NAME = "MonitorService"; export interface IMonitorService { @@ -20,7 +72,8 @@ export interface IMonitorService { teamId: string, page: number, limit: number, - type: MonitorType[] + type: MonitorType[], + status: MonitorStatus[] ) => Promise<{ count: number; upCount: number; @@ -88,14 +141,20 @@ class MonitorService implements IMonitorService { teamId: string, page: number, rowsPerPage: number, - type: MonitorType[] = [] + type: MonitorType[] = [], + status: MonitorStatus[] = [] ) => { + const teamObjectId = new mongoose.Types.ObjectId(teamId); + const statusCriteria = buildStatusCriteria(status); + const matchConditions = buildMatchConditions( + teamObjectId, + type, + statusCriteria + ); + const countResult = await Monitor.aggregate([ { - $match: { - teamId: new mongoose.Types.ObjectId(teamId), - type: { $in: type }, - }, + $match: matchConditions, }, { $group: { @@ -118,11 +177,13 @@ class MonitorService implements IMonitorService { }; const skip = page * rowsPerPage; - let find = {}; - if (type.length > 0) find = { type: { $in: type } }; - find = { ...find, teamId }; + const findConditions = buildMatchConditions( + teamObjectId, + type, + statusCriteria + ); - const monitors = await Monitor.find(find) + const monitors = await Monitor.find(findConditions) .lean() .skip(skip) .limit(rowsPerPage); diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index deb21b6cf..b7a6f9c64 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -128,6 +128,13 @@ export const monitorAllEmbedChecksQuerySchema = z.object({ if (!val) return []; return Array.isArray(val) ? val : [val]; }), + status: z + .union([z.string().optional(), z.string().optional().array()]) + .optional() + .transform((val) => { + if (!val) return []; + return Array.isArray(val) ? val : [val]; + }), page: z .string() .optional()