Merge pull request #3711 from bluewave-labs/feat/uptime-percentage

feat: uptime percentage resolves #3703
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-06-07 10:18:56 -07:00
commit 525206c1c1
10 files changed
+419 -14

No files matched your search

@@ -27,6 +27,7 @@ import {
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -161,6 +162,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
return (
<Box
@@ -178,6 +180,12 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
<Box
component="span"
sx={styles.pill}
>
{uptimePercentage}
</Box>
{monitor.url && (
<Box
component="span"
@@ -25,6 +25,8 @@ import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
monitors: Monitor[];
@@ -259,6 +261,24 @@ export const MonitorTable = ({
}
},
},
{
id: "uptime",
content: t("pages.uptime.table.headers.uptime"),
render: (row) => {
const uptimePercentage = row.uptimePercentage ?? 0;
const formattedPercentage = formatPercentage(uptimePercentage);
const paletteKey = getUptimePercentageColor(uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<Stack alignItems={"center"}>
<ColoredLabel
text={formattedPercentage}
color={color}
/>
</Stack>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
+10
View File
@@ -63,6 +63,16 @@ export const getResponseTimeColor = (responseTime: number): PaletteKey => {
}
};
export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => {
if (uptimePercentage >= 0.75) {
return "success";
} else if (uptimePercentage >= 0.5) {
return "warning";
} else {
return "error";
}
};
export const getInfraGaugeColor = (val: number, theme: any) => {
if (val < 50) {
return theme.palette.success.main;
+2 -1
View File
@@ -1607,7 +1607,8 @@
},
"table": {
"headers": {
"responseTime": "Response time"
"responseTime": "Response time",
"uptime": "Uptime %"
}
},
"fallback": {
+268
View File
@@ -0,0 +1,268 @@
import mongoose from "mongoose";
import { MonitorModel } from "../dist/db/models/Monitor.js";
import { UserModel } from "../dist/db/models/User.js";
// Seeds a large number of HTTP monitors against real, resolving domains so the
// scheduler can be exercised under load. Run after `npm run build` (imports the
// compiled models from dist/, like scripts/generate-checks.js).
//
// node scripts/seed-monitors.js # 10k monitors, owner auto-detected
// COUNT=2000 INTERVAL=30000 node scripts/seed-monitors.js
// CLEAR=true node scripts/seed-monitors.js # remove prior seed monitors first
// TEAM_ID=<id> USER_ID=<id> node scripts/seed-monitors.js
//
// NOTE: the running queue only schedules monitors it finds at init(), so restart
// the server after seeding for the new monitors to actually start checking.
const DEFAULT_COUNT = 5_000;
const DEFAULT_BATCH = 1_000;
const DEFAULT_INTERVAL = 60_000;
const NAME_PREFIX = "Seed";
// Stored in `description` so a re-run with CLEAR=true can find and remove them.
const SEED_MARKER = "checkmate-scaling-seed";
// A spread of well-known, reliably-resolving domains across regions, TLDs and
// CDNs. Monitors round-robin through this pool, so the load is split across many
// hosts rather than hammering one. Every entry returns a real HTTP response
// (some may 403/429 under load — still a valid response for scaling purposes).
const DOMAINS = [
"google.com",
"youtube.com",
"facebook.com",
"wikipedia.org",
"amazon.com",
"reddit.com",
"x.com",
"instagram.com",
"linkedin.com",
"microsoft.com",
"apple.com",
"cloudflare.com",
"github.com",
"gitlab.com",
"stackoverflow.com",
"netflix.com",
"bing.com",
"yahoo.com",
"duckduckgo.com",
"mozilla.org",
"wordpress.org",
"wordpress.com",
"adobe.com",
"salesforce.com",
"oracle.com",
"ibm.com",
"intel.com",
"nvidia.com",
"amd.com",
"dell.com",
"hp.com",
"cisco.com",
"vmware.com",
"redhat.com",
"ubuntu.com",
"debian.org",
"kernel.org",
"python.org",
"nodejs.org",
"npmjs.com",
"docker.com",
"kubernetes.io",
"golang.org",
"rust-lang.org",
"php.net",
"ruby-lang.org",
"java.com",
"spring.io",
"djangoproject.com",
"flask.palletsprojects.com",
"vuejs.org",
"reactjs.org",
"angular.io",
"svelte.dev",
"jquery.com",
"npm.im",
"pypi.org",
"rubygems.org",
"packagist.org",
"maven.apache.org",
"apache.org",
"nginx.org",
"postgresql.org",
"mysql.com",
"mongodb.com",
"redis.io",
"elastic.co",
"grafana.com",
"prometheus.io",
"hashicorp.com",
"digitalocean.com",
"linode.com",
"vultr.com",
"heroku.com",
"netlify.com",
"vercel.com",
"render.com",
"fly.io",
"railway.app",
"cloudinary.com",
"twilio.com",
"stripe.com",
"paypal.com",
"shopify.com",
"squarespace.com",
"wix.com",
"godaddy.com",
"namecheap.com",
"name.com",
"gandi.net",
"bbc.co.uk",
"theguardian.com",
"nytimes.com",
"washingtonpost.com",
"cnn.com",
"reuters.com",
"bloomberg.com",
"forbes.com",
"wsj.com",
"economist.com",
"nature.com",
"sciencedirect.com",
"arxiv.org",
"mit.edu",
"stanford.edu",
"harvard.edu",
"berkeley.edu",
"ox.ac.uk",
"cam.ac.uk",
"ethz.ch",
"spotify.com",
"soundcloud.com",
"twitch.tv",
"vimeo.com",
"dailymotion.com",
"imdb.com",
"wikimedia.org",
"archive.org",
"ietf.org",
"w3.org",
"who.int",
"un.org",
"europa.eu",
"gov.uk",
"canada.ca",
"alibaba.com",
"aliexpress.com",
"baidu.com",
"tencent.com",
"naver.com",
"rakuten.co.jp",
"yandex.com",
"ozon.ru",
"mercadolibre.com",
"flipkart.com",
"booking.com",
"airbnb.com",
"expedia.com",
"tripadvisor.com",
"uber.com",
"dropbox.com",
"box.com",
"atlassian.com",
"trello.com",
"notion.so",
"slack.com",
"zoom.us",
"asana.com",
"figma.com",
"canva.com",
];
const parseObjectId = (value) => {
if (!value) return null;
try {
return new mongoose.Types.ObjectId(value);
} catch {
console.warn(`Ignoring invalid ObjectId '${value}'`);
return null;
}
};
// Monitors need a real teamId/userId to show up for an account and to be picked
// up by the queue. Prefer explicit env ids, otherwise derive from the oldest
// existing user (the typical single-tenant dev setup).
async function resolveOwner() {
const teamId = parseObjectId(process.env.TEAM_ID);
const userId = parseObjectId(process.env.USER_ID);
if (teamId && userId) return { teamId, userId };
const user = await UserModel.findOne(userId ? { _id: userId } : {}).sort({ createdAt: 1 });
if (!user?.teamId) {
throw new Error(
"Could not determine an owner: no existing user with a teamId. " + "Create an account first, or pass TEAM_ID and USER_ID explicitly."
);
}
return { teamId: teamId ?? user.teamId, userId: user._id };
}
async function run() {
const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db";
const total = Number(process.env.COUNT ?? DEFAULT_COUNT);
const batchSize = Number(process.env.BATCH_SIZE ?? DEFAULT_BATCH);
const interval = Number(process.env.INTERVAL ?? DEFAULT_INTERVAL);
console.log(`Connecting to MongoDB at ${mongoUri}`);
await mongoose.connect(mongoUri);
const { teamId, userId } = await resolveOwner();
if (process.env.CLEAR === "true") {
const { deletedCount } = await MonitorModel.deleteMany({ description: SEED_MARKER });
console.log(`Removed ${deletedCount} previously seeded monitors`);
}
console.log(
`Seeding ${total} http monitors for team ${teamId} (user ${userId}) across ${DOMAINS.length} domains, ` +
`interval ${interval}ms, in batches of ${batchSize}`
);
const docs = [];
const startTime = Date.now();
let inserted = 0;
for (let i = 0; i < total; i += 1) {
const domain = DOMAINS[i % DOMAINS.length];
docs.push({
userId,
teamId,
name: `${NAME_PREFIX} ${String(i + 1).padStart(5, "0")} ${domain}`,
description: SEED_MARKER,
type: "http",
url: `https://${domain}`,
isActive: true,
interval,
});
if (docs.length === batchSize) {
await MonitorModel.insertMany(docs, { ordered: false });
inserted += docs.length;
console.log(`Inserted ${inserted} / ${total}`);
docs.length = 0;
}
}
if (docs.length > 0) {
await MonitorModel.insertMany(docs, { ordered: false });
inserted += docs.length;
}
await mongoose.disconnect();
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`Finished inserting ${inserted} monitors in ${duration}s`);
console.log("Restart the server so the queue schedules the new monitors.");
}
run().catch((error) => {
console.error("Failed to seed monitors", error);
process.exit(1);
});
@@ -26,6 +26,7 @@ export interface IMonitorsRepository {
// collection fetch
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
findByIds(monitorIds: string[]): Promise<Monitor[]>;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise<Monitor[]>;
@@ -85,10 +85,102 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.mapDocuments(documents);
};
findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {};
const query: Record<string, unknown> = {
teamId: new mongoose.Types.ObjectId(teamId),
};
if (type !== undefined) {
query.type = Array.isArray(type) ? { $in: type } : type;
}
if (tags !== undefined) {
query.tags = Array.isArray(tags) ? { $in: tags } : tags;
}
if (filter !== undefined) {
switch (field) {
case "name":
query.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
break;
case "isActive":
query.isActive = filter === "true";
break;
case "status":
query.status = filter;
break;
case "type":
query.type = filter;
break;
default:
break;
}
}
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const pipeline: PipelineStage[] = [
{ $match: query },
{ $sort: sort },
...(rowsPerPage ? [{ $skip: skip }, { $limit: rowsPerPage }] : []),
{
$lookup: {
from: "monitorstats",
localField: "_id",
foreignField: "monitorId",
as: "stats",
},
},
{
$addFields: {
uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] },
},
},
{
$project: {
stats: 0,
},
},
];
const documents = await MonitorModel.aggregate(pipeline);
return documents.map((doc) => this.toEntity(doc));
};
findByIds = async (monitorIds: string[]): Promise<Monitor[]> => {
if (!monitorIds.length) {
return [];
}
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const monitors = await MonitorModel.find({ _id: { $in: objectIds } });
return this.mapDocuments(monitors);
const pipeline: PipelineStage[] = [
{ $match: { _id: { $in: objectIds } } },
{
$lookup: {
from: "monitorstats",
localField: "_id",
foreignField: "monitorId",
as: "stats",
},
},
{
$addFields: {
uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] },
},
},
{
$project: {
stats: 0,
},
},
];
const documents = await MonitorModel.aggregate(pipeline);
return documents.map((doc) => this.toEntity(doc));
};
findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise<Monitor[]> => {
@@ -393,7 +393,7 @@ export class MonitorService implements IMonitorService {
}): Promise<MonitorsWithChecksByTeamIdResult> => {
const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type, tags });
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter });
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
const monitors = await this.monitorsRepository.findByTeamIdWithStats(teamId, {
limit,
type,
tags,
@@ -31,6 +31,10 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository {
return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
}
async findByTeamIdWithStats(_teamId: string, _config: TeamQueryConfig): Promise<Monitor[]> {
return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
}
async findByIds(monitorIds: string[]): Promise<Monitor[]> {
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
}
@@ -16,6 +16,7 @@ const createMonitorsRepositoryMock = () =>
createMonitors: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
findByTeamIdWithStats: jest.fn(),
findByIds: jest.fn(),
findMonitorCountByTeamIdAndType: jest.fn(),
findMonitorsSummaryByTeamId: jest.fn(),
@@ -562,7 +563,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1, upMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([
makeMonitor({
recentChecks: [
{ responseTime: 10, status: true, message: "OK" },
@@ -583,7 +584,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue(null);
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(null);
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue(null);
const { service } = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID });
@@ -596,7 +597,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([
makeMonitor({
type: "hardware",
recentChecks: [
@@ -617,7 +618,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([
makeMonitor({
type: "hardware",
recentChecks: [
@@ -637,7 +638,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([
makeMonitor({
type: "http",
recentChecks: [
@@ -657,7 +658,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 2 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([
makeMonitor({
id: "m1",
type: "http",
@@ -689,7 +690,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [] })]);
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [] })]);
const { service } = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID });
@@ -701,7 +702,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: undefined })]);
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: undefined })]);
const { service } = createService({ monitorsRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID });
@@ -713,7 +714,7 @@ describe("MonitorService", () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 0 });
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([]);
const { service } = createService({ monitorsRepository });
await service.getMonitorsWithChecksByTeamId({
@@ -727,7 +728,7 @@ describe("MonitorService", () => {
order: "asc",
});
expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith(TEAM_ID, {
expect(monitorsRepository.findByTeamIdWithStats).toHaveBeenCalledWith(TEAM_ID, {
limit: 10,
type: "http",
page: 1,