diff --git a/client/src/Components/v2/common/controls/HeaderTimeRange.tsx b/client/src/Components/v2/common/controls/HeaderTimeRange.tsx index 57b61473a..c66ee9ff6 100644 --- a/client/src/Components/v2/common/controls/HeaderTimeRange.tsx +++ b/client/src/Components/v2/common/controls/HeaderTimeRange.tsx @@ -1,7 +1,11 @@ import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs"; import { useTheme } from "@mui/material/styles"; +import CircularProgress from "@mui/material/CircularProgress"; +import { useTranslation } from "react-i18next"; +import { useMediaQuery } from "@mui/material"; interface MonitorTimeFrameHeaderProps { isLoading?: boolean; @@ -17,7 +21,8 @@ export const HeaderTimeRange = ({ setDateRange, }: MonitorTimeFrameHeaderProps) => { const theme = useTheme(); - + const { t } = useTranslation(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); const handleChange = ( _event: React.MouseEvent, newValue: string | null @@ -32,34 +37,24 @@ export const HeaderTimeRange = ({ if (hasDateRange) { timeFramePicker = ( - - Recent + + {t("components.headerTimeRange.labels.recent")} - - Day + + {t("components.headerTimeRange.labels.day")} - - Week + + {t("components.headerTimeRange.labels.week")} - - Month + + {t("components.headerTimeRange.labels.month")} ); @@ -67,21 +62,19 @@ export const HeaderTimeRange = ({ return ( + + {isLoading && } + - Showing statistics for past{" "} - {dateRange === "recent" - ? "2 hours" - : dateRange === "day" - ? "24 hours" - : dateRange === "week" - ? "7 days" - : "30 days"} - . + {t(`components.headerTimeRange.description.${dateRange}`)} {timeFramePicker} diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index 6b1c1920c..bf7979cca 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -4,12 +4,16 @@ import Box from "@mui/material/Box"; import { BaseBox } from "@/Components/v2/design-elements"; import Background from "@/assets/Images/background-grid.svg?react"; import { useTranslation } from "react-i18next"; +import type { SxProps } from "@mui/material"; import { useTheme } from "@mui/material/styles"; -type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>; +type StatusBoxProps = React.PropsWithChildren<{ + children: React.ReactNode; + sx?: SxProps; +}>; -export const BGBox = ({ children }: StatusBoxProps) => { +export const BGBox = ({ children, sx }: StatusBoxProps) => { const theme = useTheme(); return ( { position: "relative", flex: 1, padding: theme.spacing(4), + ...sx, }} > { const theme = useTheme(); return ( - + { /> ); }; +export const TotalChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; +export const DownChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; +export const UpChecksBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; export const InitializingStatusBox = ({ n }: { n: number }) => { const theme = useTheme(); diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index aa71f01af..08ef9a9a5 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -21,7 +21,7 @@ export const useGet = ( axiosConfig?: AxiosRequestConfig, swrConfig?: SWRConfiguration ) => { - const { data, error, isLoading, mutate } = useSWR>( + const { data, error, isLoading, isValidating, mutate } = useSWR>( url, (url: string) => fetcher(url, axiosConfig), swrConfig @@ -30,6 +30,7 @@ export const useGet = ( return { data: data?.data ?? null, isLoading, + isValidating, error, refetch: mutate, }; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx new file mode 100644 index 000000000..6464be25c --- /dev/null +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -0,0 +1,132 @@ +import { + Table, + Pagination, + ValueLabel, + StatusLabel, +} from "@/Components/v2/design-elements"; +import Box from "@mui/material/Box"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import type { Monitor, MonitorStatus } from "@/Types/Monitor"; + +import { useTranslation } from "react-i18next"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { useNavigate } from "react-router"; +import type { Check } from "@/Types/Check"; +import type { RootState } from "@/Types/state"; +import { useSelector } from "react-redux"; + +export const ChecksTable = ({ + monitors, + checks, + checksCount, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + monitors: Monitor[] | null; + checks: Check[]; + checksCount: 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 navigate = useNavigate(); + + const getHeaders = (t: Function, uiTimezone: string) => { + const headers: Header[] = [ + { + id: "monitorName", + content: t("common.table.headers.monitor"), + render: (row) => { + return ( + monitors?.find((monitor) => monitor.id === row.metadata.monitorId)?.name || + "N/A" + ); + }, + }, + { + id: "status", + content: "Status", + render: (row) => { + return ; + }, + }, + { + id: "date", + content: t("common.table.headers.dateTime"), + render: (row) => { + return formatDateWithTz( + row.createdAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + ); + }, + }, + { + id: "statusCode", + content: t("pages.checks.table.headers.statusCode"), + render: (row) => { + const code = row.statusCode; + if (!code) return "N/A"; + const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; + return ( + + ); + }, + }, + { + id: "message", + content: t("common.table.headers.message"), + render: (row) => { + return row.message || "N/A"; + }, + }, + ]; + return headers; + }; + + const headers = getHeaders(t, uiTimezone); + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + return ( + + { + navigate(`/checks/${row.id}`); + }} + emptyViewText={t("pages.checks.table.empty")} + /> + + + ); +}; diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx new file mode 100644 index 000000000..fa2adfae9 --- /dev/null +++ b/client/src/Pages/Checks/index.tsx @@ -0,0 +1,176 @@ +import Stack from "@mui/material/Stack"; +import { + BasePage, + TotalChecksBox, + UpChecksBox, + DownChecksBox, +} from "@/Components/v2/design-elements"; +import { HeaderTimeRange } from "@/Components/v2/common"; +import { Select } from "@/Components/v2/inputs"; +import { ChecksTable } from "./Components/ChecksTable"; + +import { MenuItem } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useState, useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useGet } from "@/Hooks/UseApi"; +import type { Monitor } from "@/Types/Monitor"; +import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; + +const Checks = () => { + const { t } = useTranslation(); + const { monitorId } = useParams<{ monitorId?: string }>(); + + const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); + const [dateRange, setDateRange] = useState("recent"); + const [statusFilter, setStatusFilter] = useState("down"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + const monitorsUrl = "/monitors/team"; + const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`; + + const { data: monitorsResponse, isLoading: isLoadingMonitors } = + useGet(monitorsUrl); + + const { data: summaryResponse, isLoading: isLoadingSummary } = + useGet(summaryUrl); + + const selectedMonitorType = monitorsResponse?.find( + (m) => m.id === selectedMonitor + )?.type; + + const teamChecksUrl = useMemo(() => { + if (selectedMonitor !== "0") return null; + const params = new URLSearchParams(); + params.append("sortOrder", "desc"); + if (dateRange) params.append("dateRange", dateRange); + if (statusFilter) params.append("filter", statusFilter); + params.append("page", String(page)); + params.append("rowsPerPage", String(rowsPerPage)); + return `/checks/team?${params.toString()}`; + }, [selectedMonitor, dateRange, statusFilter, page, rowsPerPage]); + + const monitorChecksUrl = useMemo(() => { + if (selectedMonitor === "0" || !selectedMonitorType) return null; + const params = new URLSearchParams(); + params.append("type", selectedMonitorType); + params.append("sortOrder", "desc"); + if (statusFilter) params.append("filter", statusFilter); + if (dateRange) params.append("dateRange", dateRange); + params.append("page", String(page)); + params.append("rowsPerPage", String(rowsPerPage)); + return `/checks/${selectedMonitor}?${params.toString()}`; + }, [selectedMonitor, selectedMonitorType, dateRange, statusFilter, page, rowsPerPage]); + + const { + data: teamChecksData, + isLoading: isLoadingTeamChecks, + isValidating: isValidatingTeamChecks, + } = useGet( + teamChecksUrl, + {}, + { keepPreviousData: true, refreshInterval: 30000 } + ); + const { + data: monitorChecksData, + isLoading: isLoadingMonitorChecks, + isValidating: isValidatingMonitorChecks, + } = useGet( + monitorChecksUrl, + {}, + { keepPreviousData: true, refreshInterval: 30000 } + ); + + const checks = + selectedMonitor === "0" + ? (teamChecksData?.checks ?? []) + : (monitorChecksData?.checks ?? []); + const checksCount = + selectedMonitor === "0" + ? (teamChecksData?.checksCount ?? 0) + : (monitorChecksData?.checksCount ?? 0); + + const isLoadingChecks = + isLoadingTeamChecks || + isLoadingMonitorChecks || + isValidatingTeamChecks || + isValidatingMonitorChecks; + + const isLoading = isLoadingMonitors || isLoadingSummary || isLoadingChecks; + const totalChecks = summaryResponse?.totalChecks || 0; + const downChecks = summaryResponse?.downChecks || 0; + const upChecks = totalChecks - (summaryResponse?.downChecks || 0); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default Checks; diff --git a/client/src/Pages/Checks/index.jsx b/client/src/Pages/Checks/old.jsx similarity index 100% rename from client/src/Pages/Checks/index.jsx rename to client/src/Pages/Checks/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 45715a488..5ee16a49e 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -33,7 +33,7 @@ import InfrastructureDetails from "../Pages/Infrastructure/Details/index.jsx"; import ServerUnreachable from "../Pages/ServerUnreachable.jsx"; // Checks -import Checks from "../Pages/Checks/index.jsx"; +import Checks from "../Pages/Checks/index"; // Incidents import Incidents from "../Pages/Incidents/index.jsx"; @@ -163,7 +163,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> = { - recent: new Date(new Date().setDate(new Date().getDate() - 2)), + recent: new Date(new Date().setHours(new Date().getHours() - 2)), hour: new Date(new Date().setHours(new Date().getHours() - 1)), day: new Date(new Date().setDate(new Date().getDate() - 1)), week: new Date(new Date().setDate(new Date().getDate() - 7)), @@ -193,6 +193,13 @@ class MongoChecksRepository implements IChecksRepository { }; }; + private mapDocuments = (documents: CheckDocument[]): Check[] => { + if (!documents?.length) { + return []; + } + return documents.map((doc) => this.toEntity(doc)); + }; + createChecks = async (checks: Check[]) => { return await CheckModel.insertMany(checks); }; @@ -221,9 +228,14 @@ class MongoChecksRepository implements IChecksRepository { switch (filter) { case "all": break; + case "up": + matchStage.status = true; + break; case "down": + matchStage.status = false; break; case "resolve": + matchStage.status = false; matchStage.statusCode = 5000; break; default: @@ -245,33 +257,17 @@ class MongoChecksRepository implements IChecksRepository { skip = page * rowsPerPage; } - const checks = await CheckModel.aggregate([ - { $match: matchStage }, - { $sort: { createdAt: convertedSortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { - $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], - }, - checks: { - $ifNull: ["$checks", []], - }, - }, - }, + const [checksCount, checks] = await Promise.all([ + CheckModel.countDocuments(matchStage), + CheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, ]); - return checks[0]; + + return { checksCount, checks: this.mapDocuments(checks) }; }; findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => { const matchStage: Record = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), - status: false, ...(dateRangeLookup[dateRange] && { createdAt: { $gte: dateRangeLookup[dateRange], @@ -283,9 +279,14 @@ class MongoChecksRepository implements IChecksRepository { switch (filter) { case "all": break; + case "up": + matchStage.status = true; + break; case "down": + matchStage.status = false; break; case "resolve": + matchStage.status = false; matchStage.statusCode = 5000; break; default: @@ -306,26 +307,12 @@ class MongoChecksRepository implements IChecksRepository { skip = page * rowsPerPage; } - const aggregatePipeline: any = [ - { $match: matchStage }, + const [checksCount, checks] = await Promise.all([ + CheckModel.countDocuments(matchStage), + CheckModel.find(matchStage).sort({ createdAt: parsedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise, + ]); - { $sort: { createdAt: parsedSortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, - checks: "$checks", - }, - }, - ]; - - const checks = await CheckModel.aggregate(aggregatePipeline); - return checks[0]; + return { checksCount, checks: this.mapDocuments(checks) }; }; findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { @@ -366,29 +353,24 @@ class MongoChecksRepository implements IChecksRepository { }; findSummaryByTeamId = async (teamId: string, dateRange: string) => { - const matchStage = { + const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), - status: false, ...(dateRangeLookup[dateRange] && { createdAt: { $gte: dateRangeLookup[dateRange], }, }), }; - const checks = await CheckModel.aggregate([ - { $match: matchStage }, - { - $group: { - _id: null, - totalChecks: { $sum: 1 }, - resolvedChecks: { $sum: { $cond: [{ $eq: ["$ack", true] }, 1, 0] } }, - downChecks: { $sum: { $cond: [{ $eq: ["$ack", false] }, 1, 0] } }, - cannotResolveChecks: { $sum: { $cond: [{ $eq: ["$statusCode", 5000] }, 1, 0] } }, - }, - }, - { $project: { _id: 0 } }, + + const [totalResult, downResult] = await Promise.all([ + CheckModel.countDocuments(baseMatch), + CheckModel.countDocuments({ ...baseMatch, status: false }), ]); - return checks[0] ?? { totalChecks: 0, resolvedChecks: 0, downChecks: 0, cannotResolveChecks: 0 }; + + return { + totalChecks: totalResult, + downChecks: downResult, + }; }; deleteByMonitorId = async (monitorId: string): Promise => { @@ -562,7 +544,7 @@ class MongoChecksRepository implements IChecksRepository { const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean(); return { monitorType: "pagespeed" as const, - checks: checks.map((doc) => this.toEntity(doc)), + checks: this.mapDocuments(checks), }; }; diff --git a/server/src/types/check.ts b/server/src/types/check.ts index ce932d79d..8e223d7c8 100644 --- a/server/src/types/check.ts +++ b/server/src/types/check.ts @@ -172,9 +172,7 @@ export interface UptimeChecksResult { export interface ChecksSummary { totalChecks: number; - resolvedChecks: number; downChecks: number; - cannotResolveChecks: number; } export interface HasResponseTime { diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts index 8871c29e2..1d5fcbbf0 100755 --- a/server/src/validation/joi.ts +++ b/server/src/validation/joi.ts @@ -111,7 +111,7 @@ const getMonitorByIdQueryValidation = joi.object({ status: joi.boolean(), sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), - dateRange: joi.string().valid("hour", "day", "week", "month", "all"), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), numToDisplay: joi.number(), normalize: joi.boolean(), }); @@ -307,7 +307,7 @@ const getChecksQueryValidation = joi.object({ sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "down", "resolve"), + filter: joi.string().valid("all", "up", "down", "resolve"), ack: joi.boolean(), page: joi.number(), rowsPerPage: joi.number(), @@ -317,8 +317,8 @@ const getChecksQueryValidation = joi.object({ const getTeamChecksQueryValidation = joi.object({ sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), - dateRange: joi.string().valid("hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "down", "resolve"), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "up", "down", "resolve"), ack: joi.boolean(), page: joi.number(), rowsPerPage: joi.number(),