checks route, checks table

This commit is contained in:
Alex Holliday
2026-02-24 23:20:36 +00:00
parent 651fbd7cbb
commit e7ba3cb5f9
15 changed files with 420 additions and 19 deletions
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
{
id: "date",
content: t("pages.checks.table.headers.dateTime"),
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
@@ -0,0 +1,110 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { GeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<GeoCheck>[] = [
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const firstResult = row.results[0];
const status = firstResult?.status ? "up" : "down";
return <StatusLabel status={status} />;
},
},
{
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 firstResult = row.results[0];
return firstResult?.statusCode || "N/A";
},
},
{
id: "location",
content: t("pages.checks.table.headers.location"),
render: (row) => {
const firstResult = row.results[0];
if (!firstResult) return "N/A";
const { continent, country, city } = firstResult.location;
return `${continent} - ${country}, ${city}`;
},
},
{
id: "responseTime",
content: t("common.table.headers.responseTime"),
render: (row) => {
const firstResult = row.results[0];
if (!firstResult?.timings?.total) return "N/A";
return prettyMilliseconds(firstResult.timings.total, { compact: true });
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: GeoCheck[];
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,
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={geoChecks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+52 -10
View File
@@ -10,6 +10,7 @@ import {
} from "@/Components/monitors";
import { TrendingUp, AlertTriangle } from "lucide-react";
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable";
import { MonitorStatBoxes } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
@@ -20,7 +21,7 @@ import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type { GeoChecksResult, GeoContinent } from "@/Types/GeoCheck";
import type { GeoChecksResult, GeoChecksResponse, GeoContinent } from "@/Types/GeoCheck";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
@@ -40,6 +41,8 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
@@ -113,7 +116,6 @@ const UptimeDetailsPage = () => {
{ keepPreviousData: true, revalidateOnFocus: false }
);
// Geo checks fetch - only for HTTP monitors with geo checks enabled
const geoChecksUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
return null;
@@ -124,14 +126,46 @@ const UptimeDetailsPage = () => {
return `/monitors/${monitorId}/geo-checks?${params.toString()}`;
}, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]);
const { data: _geoChecksData, isLoading: _geoChecksIsLoading } =
useGet<GeoChecksResult>(
geoChecksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const { data: geoGroupedData } = useGet<GeoChecksResult>(
geoChecksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecks = _geoChecksData?.groupedGeoChecks ?? [];
const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? [];
// Fetch paginated geo checks for the table
const geoChecksTableUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http" || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("sortOrder", "desc");
params.append("dateRange", dateRange);
params.append("page", String(geoPage));
params.append("rowsPerPage", String(geoRowsPerPage));
if (selectedLocation) {
params.append("continent", selectedLocation);
}
return `/geo-checks/${monitorId}?${params.toString()}`;
}, [
monitorId,
monitor?.type,
monitor?.geoCheckEnabled,
dateRange,
geoPage,
geoRowsPerPage,
selectedLocation,
]);
const { data: geoChecksTableData } = useGet<GeoChecksResponse>(
geoChecksTableUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
// Extract unique locations from geo checks data
const geoLocations = monitor?.geoCheckLocations;
@@ -203,9 +237,17 @@ const UptimeDetailsPage = () => {
onLocationChange={setSelectedLocation}
/>
<HistogramDetails
checks={geoChecks}
checks={geoGroupedChecks}
range={dateRange}
/>
<GeoChecksTable
geoChecks={geoChecksForTable}
count={geoChecksCount}
page={geoPage}
setPage={setGeoPage}
rowsPerPage={geoRowsPerPage}
setRowsPerPage={setGeoRowsPerPage}
/>
</>
)}
</BasePage>
+5
View File
@@ -55,3 +55,8 @@ export interface GeoChecksResult {
monitorType: string;
groupedGeoChecks: GroupedGeoCheck[];
}
export interface GeoChecksResponse {
geoChecks: GeoCheck[];
geoChecksCount: number;
}
+4 -3
View File
@@ -115,7 +115,8 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
"active": "Active"
"active": "Active",
"responseTime": "Response time"
}
}
},
@@ -372,8 +373,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
"dateTime": "Date & time",
"statusCode": "Status code"
"statusCode": "Status code",
"location": "Location"
}
}
},
+3
View File
@@ -2,6 +2,7 @@ import MonitorController from "../controllers/monitorController.js";
import AuthController from "../controllers/authController.js";
import SettingsController from "../controllers/settingsController.js";
import CheckController from "../controllers/checkController.js";
import GeoCheckController from "../controllers/geoCheckController.js";
import InviteController from "../controllers/inviteController.js";
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
import QueueController from "../controllers/queueController.js";
@@ -17,6 +18,7 @@ export interface InitializedControllers {
monitorController: MonitorController;
settingsController: SettingsController;
checkController: CheckController;
geoCheckController: GeoCheckController;
inviteController: InviteController;
maintenanceWindowController: MaintenanceWindowController;
queueController: QueueController;
@@ -32,6 +34,7 @@ export const initializeControllers = (services: InitializedServices): Initialize
monitorController: new MonitorController(services.monitorService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
geoCheckController: new GeoCheckController(services.geoChecksService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
+3
View File
@@ -6,6 +6,7 @@ import AuthRoutes from "../routes/authRoute.js";
import InviteRoutes from "../routes/inviteRoute.js";
import MonitorRoutes from "../routes/monitorRoute.js";
import CheckRoutes from "../routes/checkRoute.js";
import GeoCheckRoutes from "../routes/geoCheckRoutes.js";
import SettingsRoutes from "../routes/settingsRoute.js";
import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js";
import StatusPageRoutes from "../routes/statusPageRoute.js";
@@ -22,6 +23,7 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
const checkRoutes = new CheckRoutes(controllers.checkController);
const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController);
const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT);
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
const queueRoutes = new QueueRoutes(controllers.queueController);
@@ -36,6 +38,7 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter());
app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter());
app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter());
app.use("/api/v1/invite", inviteRoutes.getRouter());
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter());
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
+7 -2
View File
@@ -154,7 +154,12 @@ export const initializeServices = async ({
const globalPingService = new GlobalPingService(logger);
const geoChecksService = new GeoChecksService(logger, geoChecksRepository, globalPingService);
const geoChecksService = new GeoChecksService({
logger,
geoChecksRepository,
globalPingService,
monitorsRepository,
});
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
@@ -252,12 +257,12 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
monitorService,
incidentService,
geoChecksService,
logger,
notificationsService,
statusPageService,
@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from "express";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/joi.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
const SERVICE_NAME = "geoCheckController";
class GeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService;
constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService;
}
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(req.query);
const result = await this.geoChecksService.getGeoChecksByMonitor({
monitorId: req?.params?.monitorId as string,
query: req?.query,
teamId: req?.user?.teamId as string,
});
return res.status(200).json({
success: true,
msg: "Geo checks retrieved successfully",
data: result,
});
} catch (error) {
next(error);
}
};
}
export default GeoCheckController;
@@ -1,8 +1,21 @@
import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js";
import type { GeoContinent } from "@/types/geoCheck.js";
export interface GeoChecksQueryResult {
geoChecksCount: number;
geoChecks: GeoCheck[];
}
export interface IGeoChecksRepository {
createGeoChecks(geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]>;
findByMonitorId(
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continent?: GeoContinent
): Promise<GeoChecksQueryResult>;
findByMonitorIdAndDateRange(monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]>;
findGroupedByMonitorIdAndDateRange(
monitorId: string,
@@ -1,10 +1,18 @@
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck } from "@/types/geoCheck.js";
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent } from "@/types/geoCheck.js";
import type { GeoChecksQueryResult } from "./IGeoChecksRepository.js";
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
const SERVICE_NAME = "GeoChecksRepository";
const dateRangeLookup: Record<string, Date> = {
recent: new Date(Date.now() - 60 * 60 * 1000),
day: new Date(Date.now() - 24 * 60 * 60 * 1000),
week: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
month: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
};
class MongoGeoChecksRepository implements IGeoChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
@@ -97,6 +105,93 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
}
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continent?: GeoContinent
): Promise<GeoChecksQueryResult> => {
try {
const matchStage: Record<string, any> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(dateRangeLookup[dateRange] && {
createdAt: {
$gte: dateRangeLookup[dateRange],
},
}),
};
const convertedSortOrder = sortOrder === "asc" ? 1 : -1;
let skip = 0;
if (page && rowsPerPage) {
skip = page * rowsPerPage;
}
if (continent) {
const pipeline: any[] = [
{ $match: matchStage },
{ $unwind: "$results" },
{
$match: {
"results.location.continent": continent,
},
},
{
$group: {
_id: "$_id",
doc: { $first: "$$ROOT" },
results: { $push: "$results" },
},
},
{
$replaceRoot: {
newRoot: {
$mergeObjects: ["$doc", { results: "$results" }],
},
},
},
{ $sort: { createdAt: convertedSortOrder } },
{ $skip: skip },
{ $limit: rowsPerPage },
];
const [countPipeline, dataResults] = await Promise.all([
GeoCheckModel.aggregate([
{ $match: matchStage },
{ $unwind: "$results" },
{ $match: { "results.location.continent": continent } },
{ $group: { _id: "$_id" } },
{ $count: "count" },
]),
GeoCheckModel.aggregate(pipeline),
]);
const geoChecksCount = countPipeline[0]?.count || 0;
const geoChecks = dataResults.map(this.toEntity);
return { geoChecksCount, geoChecks };
} else {
const [geoChecksCount, docs] = await Promise.all([
GeoCheckModel.countDocuments(matchStage),
GeoCheckModel.find(matchStage).sort({ createdAt: convertedSortOrder }).skip(skip).limit(rowsPerPage).lean() as Promise<GeoCheckDocument[]>,
]);
return { geoChecksCount, geoChecks: docs.map(this.toEntity) };
}
} catch (error: any) {
this.logger.error({
message: `Error finding geo checks by monitor ID: ${error.message}`,
service: SERVICE_NAME,
method: "findByMonitorId",
stack: error.stack,
});
throw error;
}
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
try {
const docs = await GeoCheckModel.find({
+22
View File
@@ -0,0 +1,22 @@
import { Router } from "express";
class GeoCheckRoutes {
private router: Router;
private geoCheckController: any;
constructor(geoCheckController: any) {
this.router = Router();
this.geoCheckController = geoCheckController;
this.initRoutes();
}
initRoutes() {
this.router.get("/:monitorId", this.geoCheckController.getGeoChecksByMonitor);
}
getRouter() {
return this.router;
}
}
export default GeoCheckRoutes;
@@ -2,9 +2,10 @@ import type { Monitor, GeoCheck } from "@/types/index.js";
import type { GeoCheckResult } from "@/types/geoCheck.js";
import { Types } from "mongoose";
import type { IGeoChecksRepository } from "@/repositories/index.js";
import type { IMonitorsRepository } from "@/repositories/index.js";
import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js";
import type { ILogger } from "@/utils/logger.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "GeoChecksService";
@@ -12,6 +13,7 @@ export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise<any>;
}
class GeoChecksService implements IGeoChecksService {
@@ -20,11 +22,23 @@ class GeoChecksService implements IGeoChecksService {
private logger: ILogger;
private geoChecksRepository: IGeoChecksRepository;
private globalPingService: IGlobalPingService;
private monitorsRepository: IMonitorsRepository;
constructor(logger: ILogger, geoChecksRepository: IGeoChecksRepository, globalPingService: IGlobalPingService) {
constructor({
logger,
geoChecksRepository,
globalPingService,
monitorsRepository,
}: {
logger: ILogger;
geoChecksRepository: IGeoChecksRepository;
globalPingService: IGlobalPingService;
monitorsRepository: IMonitorsRepository;
}) {
this.logger = logger;
this.geoChecksRepository = geoChecksRepository;
this.globalPingService = globalPingService;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
@@ -133,6 +147,43 @@ class GeoChecksService implements IGeoChecksService {
createGeoChecks = async (geoChecks: GeoCheck[]) => {
return this.geoChecksRepository.createGeoChecks(geoChecks);
};
getGeoChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
if (!monitorId) {
throw new AppError({
message: "No monitor ID in request",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 400,
});
}
if (!teamId) {
throw new AppError({
message: "No team ID in request",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 400,
});
}
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({
message: `Monitor with ID ${monitorId} not found.`,
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
status: 404,
});
}
let { sortOrder, dateRange, page, rowsPerPage, continent } = query;
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continent);
return result;
};
}
export default GeoChecksService;
@@ -479,6 +479,14 @@ export class MonitorService implements IMonitorService {
});
});
await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
this.logger.warn({
message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
});
});
await this.jobQueue.deleteJob(monitor);
return monitor;
};
@@ -490,6 +498,7 @@ export class MonitorService implements IMonitorService {
try {
await this.jobQueue.deleteJob(monitor);
await this.checksRepository.deleteByMonitorId(monitor.id);
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
} catch (error: any) {
+1
View File
@@ -324,6 +324,7 @@ const getChecksQueryValidation = joi.object({
page: joi.number(),
rowsPerPage: joi.number(),
status: joi.boolean(),
continent: joi.string().valid(...GeoContinents),
});
const getTeamChecksQueryValidation = joi.object({