diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
index 71aa85a89..b19eea0f4 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
@@ -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 (
{
>
{getMonitorTypeLabel(monitor.type, t)}
+
+ {uptimePercentage}
+
{monitor.url && (
{
+ const uptimePercentage = row.uptimePercentage ?? 0;
+ const formattedPercentage = formatPercentage(uptimePercentage);
+ const paletteKey = getUptimePercentageColor(uptimePercentage);
+ const color = theme.palette[paletteKey].main;
+ return (
+
+
+
+ );
+ },
+ },
{
id: "tags",
content: t("common.table.headers.tags"),
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index 256d523de..401771f94 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -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;
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 0b78ba1e9..e5f7073b6 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -1607,7 +1607,8 @@
},
"table": {
"headers": {
- "responseTime": "Response time"
+ "responseTime": "Response time",
+ "uptime": "Uptime %"
}
},
"fallback": {
diff --git a/server/scripts/seed-monitors.js b/server/scripts/seed-monitors.js
new file mode 100644
index 000000000..b316eec4f
--- /dev/null
+++ b/server/scripts/seed-monitors.js
@@ -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= USER_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);
+});
diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts
index 90fcc5a94..dcd01f3c9 100644
--- a/server/src/repositories/monitors/IMonitorsRepository.ts
+++ b/server/src/repositories/monitors/IMonitorsRepository.ts
@@ -26,6 +26,7 @@ export interface IMonitorsRepository {
// collection fetch
findAll(): Promise;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise;
+ findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise;
findByIds(monitorIds: string[]): Promise;
findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise;
diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts
index 3d1f9c3a5..0b9de8ace 100644
--- a/server/src/repositories/monitors/MongoMonitorsRepository.ts
+++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts
@@ -85,10 +85,102 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.mapDocuments(documents);
};
+ findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise => {
+ const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {};
+
+ const query: Record = {
+ 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 => {
+ 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 => {
diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts
index 46b81a2ca..d172107c9 100644
--- a/server/src/service/business/monitorService.ts
+++ b/server/src/service/business/monitorService.ts
@@ -393,7 +393,7 @@ export class MonitorService implements IMonitorService {
}): Promise => {
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,
diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts
index a151ee41e..756a7bfb7 100644
--- a/server/test/helpers/InMemoryMonitorsRepository.ts
+++ b/server/test/helpers/InMemoryMonitorsRepository.ts
@@ -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 {
+ return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m }));
+ }
+
async findByIds(monitorIds: string[]): Promise {
return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m }));
}
diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts
index 5e73ab77b..a21967de8 100644
--- a/server/test/unit/services/monitorService.test.ts
+++ b/server/test/unit/services/monitorService.test.ts
@@ -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,