Merge pull request #3215 from bluewave-labs/feat/v2-checks

feat: v2 checks
This commit is contained in:
Alexander Holliday
2026-01-28 11:28:10 -08:00
committed by GitHub
12 changed files with 458 additions and 103 deletions
@@ -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<HTMLElement>,
newValue: string | null
@@ -32,34 +37,24 @@ export const HeaderTimeRange = ({
if (hasDateRange) {
timeFramePicker = (
<ToggleButtonGroup
orientation={isSmallScreen ? "vertical" : "horizontal"}
value={dateRange}
exclusive
onChange={handleChange}
size="small"
fullWidth={isSmallScreen}
>
<ToggleButton
disabled={isLoading}
value="recent"
>
Recent
<ToggleButton value="recent">
{t("components.headerTimeRange.labels.recent")}
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="day"
>
Day
<ToggleButton value="day">
{t("components.headerTimeRange.labels.day")}
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="week"
>
Week
<ToggleButton value="week">
{t("components.headerTimeRange.labels.week")}
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="month"
>
Month
<ToggleButton value="month">
{t("components.headerTimeRange.labels.month")}
</ToggleButton>
</ToggleButtonGroup>
);
@@ -67,21 +62,19 @@ export const HeaderTimeRange = ({
return (
<Stack
direction="row"
direction={{ xs: "column", md: "row" }}
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
>
<Box
width={16}
height={16}
>
{isLoading && <CircularProgress size={16} />}
</Box>
<Typography variant="body2">
Showing statistics for past{" "}
{dateRange === "recent"
? "2 hours"
: dateRange === "day"
? "24 hours"
: dateRange === "week"
? "7 days"
: "30 days"}
.
{t(`components.headerTimeRange.description.${dateRange}`)}
</Typography>
{timeFramePicker}
</Stack>
@@ -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 (
<BaseBox
@@ -18,6 +22,7 @@ export const BGBox = ({ children }: StatusBoxProps) => {
position: "relative",
flex: 1,
padding: theme.spacing(4),
...sx,
}}
>
<Box
@@ -36,14 +41,16 @@ const StatusBox = ({
label,
n,
color,
sx,
}: {
label: string;
n: number;
color: string | undefined;
sx?: SxProps;
}) => {
const theme = useTheme();
return (
<BGBox>
<BGBox sx={sx}>
<Stack spacing={theme.spacing(4)}>
<Typography
variant={"h2"}
@@ -98,6 +105,39 @@ export const PausedStatusBox = ({ n }: { n: number }) => {
/>
);
};
export const TotalChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.total")}
n={n}
color={theme.palette.primary.light}
/>
);
};
export const DownChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.down")}
n={n}
color={theme.palette.error.light}
/>
);
};
export const UpChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.up")}
n={n}
color={theme.palette.success.light}
/>
);
};
export const InitializingStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
+2 -1
View File
@@ -21,7 +21,7 @@ export const useGet = <T>(
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
const { data, error, isLoading, isValidating, mutate } = useSWR<ApiResponse<T>>(
url,
(url: string) => fetcher<T>(url, axiosConfig),
swrConfig
@@ -30,6 +30,7 @@ export const useGet = <T>(
return {
data: data?.data ?? null,
isLoading,
isValidating,
error,
refetch: mutate,
};
@@ -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<Check>[] = [
{
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 <StatusLabel status={row.status as MonitorStatus} />;
},
},
{
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 (
<ValueLabel
value={value}
text={String(code)}
/>
);
},
},
{
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<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
return (
<Box>
<Table
headers={headers}
data={checks}
onRowClick={(row) => {
navigate(`/checks/${row.id}`);
}}
emptyViewText={t("pages.checks.table.empty")}
/>
<Pagination
component="div"
count={checksCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+176
View File
@@ -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<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const monitorsUrl = "/monitors/team";
const summaryUrl = `/checks/team/summary?dateRange=${dateRange}`;
const { data: monitorsResponse, isLoading: isLoadingMonitors } =
useGet<Monitor[]>(monitorsUrl);
const { data: summaryResponse, isLoading: isLoadingSummary } =
useGet<ChecksSummary>(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<ChecksResponse>(
teamChecksUrl,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const {
data: monitorChecksData,
isLoading: isLoadingMonitorChecks,
isValidating: isValidatingMonitorChecks,
} = useGet<ChecksResponse>(
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 (
<BasePage>
<Stack
direction={{ xs: "column", md: "row" }}
gap={4}
>
<TotalChecksBox n={totalChecks} />
<UpChecksBox n={upChecks} />
<DownChecksBox n={downChecks || 0} />
</Stack>
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="space-between"
alignItems={{ xs: "stretch", md: "center" }}
gap={2}
>
<Stack
gap={2}
direction={{ xs: "column", md: "row" }}
>
<Select
fullWidth
value={selectedMonitor}
onChange={(e: any) => {
setSelectedMonitor(e.target.value);
setPage(0);
}}
>
<MenuItem value="0">{t("pages.checks.selects.monitor.all")}</MenuItem>
{monitorsResponse?.map((monitor) => (
<MenuItem
key={monitor.id}
value={monitor.id}
>
{monitor.name}
</MenuItem>
))}
</Select>
<Select
value={statusFilter}
onChange={(e: any) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<MenuItem value="all">{t("pages.checks.selects.status.all")}</MenuItem>
<MenuItem value="up">{t("pages.checks.selects.status.up")}</MenuItem>
<MenuItem value="down">{t("pages.checks.selects.status.down")}</MenuItem>
</Select>
</Stack>
<HeaderTimeRange
isLoading={isLoading || isLoadingChecks}
dateRange={dateRange}
setDateRange={setDateRange}
/>
</Stack>
<ChecksTable
monitors={monitorsResponse ?? null}
checks={checks}
checksCount={checksCount}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
</BasePage>
);
};
export default Checks;
+8 -2
View File
@@ -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 = () => {
/>
<Route
path="checks/:monitorId?"
element={<Checks />}
element={
<>
<ThemeProvider theme={v2theme}>
<Checks />
</ThemeProvider>
</>
}
/>
<Route
path="incidents/:monitorId?"
-2
View File
@@ -222,9 +222,7 @@ export interface UptimeChecksResult {
export interface ChecksSummary {
totalChecks: number;
resolvedChecks: number;
downChecks: number;
cannotResolveChecks: number;
}
export type CheckSnapshot = Omit<
+30 -1
View File
@@ -40,7 +40,8 @@
"monitor": "Monitor",
"name": "Name",
"status": "Status",
"type": "Type"
"type": "Type",
"dateTime": "Date & time"
},
"empty": "Nothing here"
},
@@ -170,6 +171,22 @@
},
"add": "Add",
"monitors": "monitors",
"components": {
"headerTimeRange": {
"labels": {
"recent": "Recent",
"day": "Day",
"week": "Week",
"month": "Month"
},
"description": {
"recent": "Showing statistics for past 2 hours.",
"day": "Showing statistics for past 24 hours.",
"week": "Showing statistics for past 7 days.",
"month": "Showing statistics for past 30 days."
}
}
},
"pages": {
"common": {
"monitors": {
@@ -177,6 +194,7 @@
"up": "up",
"down": "down",
"paused": "paused",
"total": "total",
"initializing": "initializing"
},
"actions": {
@@ -223,6 +241,17 @@
"headers": {
"dateTime": "Date & time",
"statusCode": "Status code"
},
"empty": "No down checks in this time range"
},
"selects": {
"monitor": {
"all": "All monitors"
},
"status": {
"all": "All",
"down": "Down",
"up": "Up"
}
}
},
@@ -19,7 +19,7 @@ import mongoose from "mongoose";
const SERVICE_NAME = "StatusService";
const dateRangeLookup: Record<string, Date | undefined> = {
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<CheckDocument[]>,
]);
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<string, any> = {
"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<CheckDocument[]>,
]);
{ $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<LatestChecksMap> => {
@@ -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<number> => {
@@ -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),
};
};
-2
View File
@@ -172,9 +172,7 @@ export interface UptimeChecksResult {
export interface ChecksSummary {
totalChecks: number;
resolvedChecks: number;
downChecks: number;
cannotResolveChecks: number;
}
export interface HasResponseTime {
+4 -4
View File
@@ -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(),