Compare commits

...
Author SHA1 Message Date
Alex Holliday a54fdd89ab rearm jobs on maintenance window pause/delete 2026-06-18 10:17:33 -07:00
Alexander Holliday 6aa9360b98 Merge pull request #3731 from bluewave-labs/fix/evluation-order
fix: check evaluation order
2026-06-18 10:04:31 -07:00
Alex Holliday 398e5d7314 add tests 2026-06-18 09:57:05 -07:00
Alex Holliday 53ee45172d remove evluation from worker 2026-06-18 09:56:56 -07:00
Alex Holliday 1ea6dafb1a evaluate checks on buffer flush 2026-06-18 09:56:38 -07:00
Alex Holliday 9785dc146e inject job repo into buffer service 2026-06-18 09:56:22 -07:00
Alexander Holliday 1e2a4b4a84 Merge pull request #3730 from bluewave-labs/feat/additional-mongo-stats
feat: additional mongo stats
2026-06-17 11:09:30 -07:00
Alex Holliday 5e0d8f0550 update tests 2026-06-17 11:01:56 -07:00
Alex Holliday bffc4ee813 add stats to fe 2026-06-17 10:55:49 -07:00
Alex Holliday ad9ff7f5df add mongo read/write per second stats 2026-06-17 10:55:39 -07:00
Alexander Holliday 8277c4ed13 Merge pull request #3728 from bluewave-labs/feat/db-queue
feat: db queue
2026-06-17 09:48:40 -07:00
Alex Holliday f65238da6c reformat 2026-06-17 09:45:59 -07:00
Alex Holliday 64b8aab4d6 repair tests 2026-06-17 09:40:51 -07:00
Alex Holliday 7244b35091 add upsertCleanup to schedule immediate cleanup jobs, cleanup orphaned jobs 2026-06-17 09:38:21 -07:00
Alex Holliday 699883e0b1 add jitter and tests 2026-06-16 11:34:58 -07:00
Alex Holliday f7211711ce add db queue worker tests 2026-06-16 10:59:56 -07:00
Alex Holliday cb0dd878d6 update .env.example 2026-06-16 10:59:46 -07:00
Alex Holliday 8c5be9ef00 removed pulsecron and ioredis 2026-06-16 10:50:50 -07:00
Alex Holliday d1b6d08c73 add tests 2026-06-16 10:50:39 -07:00
Alex Holliday 2d68e96782 drop jobs in migration 2026-06-16 10:50:29 -07:00
Alex Holliday 306f2a60a2 update tests 2026-06-15 12:22:55 -07:00
Alex Holliday abf6dc2908 single worker ID 2026-06-15 12:21:36 -07:00
Alex Holliday 7ce6068757 fix migration 2026-06-15 12:12:14 -07:00
Alex Holliday a7d9bb822e add tests, fix failing tests 2026-06-15 12:05:31 -07:00
Alex Holliday 142ecaaf8b handle renewing locks for long running jobs 2026-06-15 12:05:15 -07:00
Alex Holliday 90bbc764fc readd queue-worker domain, add heartbewat to db-queue 2026-06-15 11:26:49 -07:00
Alex Holliday ae4c433246 add a feature flag to toggle primary worker processing jobs or not 2026-06-15 11:02:46 -07:00
Alex Holliday d2461ef118 remove old queues 2026-06-15 10:39:48 -07:00
Alex Holliday 47162f2064 add a batch claim method 2026-06-12 16:04:31 -07:00
Alex Holliday 8b9c41464a dbQueueWorker 2026-06-12 15:43:36 -07:00
Alex Holliday 51b6ec1751 add check producer and evaluator 2026-06-12 12:36:50 -07:00
Alex Holliday eff4f8ad69 fix method name 2026-06-11 15:20:13 -07:00
Alex Holliday bca902471d update tests 2026-06-11 15:19:53 -07:00
Alex Holliday 1b3f2f0365 add toStatusResponse to service, add findUnevaluated to repo 2026-06-11 15:19:45 -07:00
Alex Holliday be12991997 add tests 2026-06-11 14:19:21 -07:00
Alex Holliday 54596fe776 add mongodb-memory-server for testing 2026-06-11 14:19:14 -07:00
Alex Holliday 8d637dbcac update validation 2026-06-11 14:18:56 -07:00
Alex Holliday 771fd5b50f add migration for lastEvaluatedAt backfill 2026-06-11 14:18:44 -07:00
Alex Holliday 8dedf2a742 add last evaluated at field 2026-06-11 14:18:32 -07:00
Alex Holliday c882daf000 add job domain 2026-06-11 14:18:21 -07:00
Alexander Holliday 1aa7ae8f11 Merge pull request #3715 from Cincinnatus101010/feat/status-page-custom-domain
feat: custom domain support for published status pages
2026-06-11 12:31:20 -07:00
Ian Troisi 517139cbf4 fix: cache negative custom domain CORS lookups
Use a shorter TTL for rejected origins so unknown domains do not hit
the database on every cross-origin request.
2026-06-11 14:48:31 -04:00
Ian Troisi ef049b6521 fix: resolve CORS TypeScript error and format server files
Type defaultCorsOptions as CorsOptions so allowedHeaders is compatible
with the cors middleware. Format files from develop merge for CI.
2026-06-11 13:55:10 -04:00
Ian Troisi 33f8641f3a Merge branch 'develop' into feat/status-page-custom-domain 2026-06-11 13:54:27 -04:00
Alexander Holliday 2f22a52116 Merge commit from fork
Advisory fix 1
2026-06-11 10:44:42 -07:00
Ian Troisi 8af13b04ec fix: derive custom domain URL protocol from client host
Use the configured client host protocol instead of hardcoding https for
custom domain status page links.
2026-06-11 13:43:19 -04:00
Alex Holliday ac77216f07 add middleware tests 2026-06-11 10:42:15 -07:00
Alex Holliday a43363ebfc apply middleware 2026-06-11 10:42:05 -07:00
Alex Holliday 0ca738b080 add an upload type with size constraints and acceptable file types 2026-06-11 10:41:53 -07:00
Alex Holliday 091c36cbf3 create upload middleware 2026-06-11 10:40:59 -07:00
Ian Troisi 120708ed47 perf: cache custom domain CORS origin lookups
Avoid hitting the database on every cross-origin request by caching
published custom domain checks for five minutes.
2026-06-11 13:15:02 -04:00
Ian Troisi 6a1ab622b3 fix: scope custom domain CORS to public status page routes
Only allow registered custom domain origins on public GET/OPTIONS status
page API routes instead of the entire application API surface.
2026-06-11 13:15:02 -04:00
Ian Troisi 0aef8b652f fix: use partial index for status page customDomain
Replace the sparse unique index with a partial index so multiple status
pages without a custom domain no longer collide on null values.
2026-06-11 13:14:52 -04:00
Alexander Holliday 20863fbce7 Merge pull request #3725 from bluewave-labs/feat/decouple-worker
feat: decouple worker
2026-06-10 13:24:11 -07:00
Alex Holliday fa7eaa4788 update tests 2026-06-10 13:13:01 -07:00
Alex Holliday ee9c08b1bb extract reactors and pipeline 2026-06-10 13:12:51 -07:00
Alexander Holliday 7ee30e12f5 Merge pull request #3724 from bluewave-labs/chore/worker-reorg
chore: worker reorg
2026-06-10 09:34:31 -07:00
Alex Holliday c5f57eda62 update tests 2026-06-10 09:30:24 -07:00
Alex Holliday 2bf3c08f3c reorg jobqueue to worker 2026-06-10 09:30:14 -07:00
Ian Troisi 187c8eaba1 chore: format openapi.json with prettier 2026-06-10 11:03:34 -04:00
Ian Troisi a37bc63f31 Merge branch 'develop' into feat/status-page-custom-domain
Resolve conflicts from server reorganization while preserving custom
domain support for published status pages.
2026-06-10 10:55:59 -04:00
Alexander Holliday c8c4bac2c7 Merge pull request #3719 from bluewave-labs/chore/organization
chore: organization
2026-06-09 12:40:06 -07:00
Alex Holliday 9fecfa562f update claude.md 2026-06-09 12:36:38 -07:00
Alex Holliday 7991f3f6a1 ignore unknown on format on client 2026-06-09 12:36:16 -07:00
Alex Holliday 692d4d89d6 update configs 2026-06-09 12:35:57 -07:00
Alex Holliday f1b104b78d ignore unknown on format 2026-06-09 12:35:19 -07:00
Alex Holliday 3c2b571313 update openapi docs 2026-06-09 12:34:57 -07:00
Alex Holliday abec4e6c70 update tests 2026-06-09 12:34:30 -07:00
Alex Holliday d022f7d849 reorg to domain and api 2026-06-09 12:34:15 -07:00
Alexander Holliday 0fb210e9e1 Merge pull request #3713 from Rabson/fix/diagnostics-totalsize-undefined
fix: (diagnostics) derive Mongo totalSize when dbStats omits it (Atlas shared tier)
2026-06-09 09:09:47 -07:00
Alexander Holliday ce4cb63035 Merge pull request #3690 from harsh-aghara/feat/accept-status-codes-backend
Add customUpCodes support for HTTP monitors (Backend)
2026-06-08 12:04:42 -07:00
harsh-aghara f637b01631 refactor: introduced helper method to remove redundant code 2026-06-08 17:30:26 +05:30
Ian Troisi e390bd764f Format openapi.json for CI format-check. 2026-06-07 22:42:58 -04:00
Ian Troisi 30e8cfc9ec Add custom domain support for published status pages.
Fixes #3647
2026-06-07 22:37:24 -04:00
Alexander Holliday 80d6868a1d Merge pull request #3714 from bluewave-labs/fix/status-page-styling
fix: status page styling
2026-06-07 15:23:06 -07:00
Alex Holliday 942d75afcd sm -> md 2026-06-07 15:21:53 -07:00
Alex Holliday c34fcc5cb3 fix responsiveness for status page themes 2026-06-07 15:18:22 -07:00
Alex Holliday 409f9f6dc6 fix status page styling 2026-06-07 10:54:58 -07:00
Alexander Holliday 525206c1c1 Merge pull request #3711 from bluewave-labs/feat/uptime-percentage
feat: uptime percentage resolves #3703
2026-06-07 10:18:56 -07:00
Alex Holliday 9a85389b8c update tests 2026-06-07 10:16:31 -07:00
Alex Holliday eeac784922 dispaly uptime percentage on status pages 2026-06-07 10:02:44 -07:00
Alex Holliday 19b873ca87 convert findByIds to aggregate pipeline to include uptime percentage 2026-06-07 10:02:25 -07:00
Alex Holliday 0f99c7004d remove unused import 2026-06-07 09:44:44 -07:00
Rabson 6a566c3693 fix(diagnostics): derive Mongo totalSize when dbStats omits it
MongoDB Atlas shared/free tiers return a legacy dbStats shape that omits
`totalSize` (while still returning storageSize/indexSize). diagnosticService
passed that undefined straight through, so the Diagnostics UI crashed with
"Expected a finite number, got undefined".

Derive totalSize (storageSize + indexSize) at the DB and per-collection level
so it always satisfies the non-optional `totalSize: number` type. Confirmed it
is not a version removal (stock mongo:8 returns totalSize) nor a permissions
issue (missing even with atlasAdmin). Adds unit tests for the derivation.
2026-06-07 08:58:09 +08:00
Alex dfcf0309fe add uptime percentage label 2026-06-06 16:08:35 -07:00
Alex 395b7fbe2a add uptimetime stats method and aggregation 2026-06-06 16:08:00 -07:00
harsh-aghara d2c13fac5a Merge remote-tracking branch 'upstream/develop' into feat/accept-status-codes-backend
# Conflicts:
#	server/src/service/infrastructure/network/HttpProvider.ts
2026-06-05 00:58:35 +05:30
harsh-aghara dd16ac060a fix: run advanced matcher on HTTP errors with custom up codes
When customUpCodes mark an HTTPError as up, parse the response body, apply advancedMatcher.validate, and set status to statusUp && matchResult.ok. Return parsed payload and extracted values to match the success path.
2026-06-05 00:35:30 +05:30
harsh-aghara 5628c202f7 refactor: extract isStatusUp utility to decouple from got's response.ok and modified utils test accordingly 2026-06-04 22:43:43 +05:30
harsh-aghara 372fc9869b refactor(monitor): address PR feedback for customUpCodes
Add HttpStatusCode types and simplify provider logic
2026-06-03 23:39:26 +05:30
harsh-aghara 87a6640c1d fix(backend): validate customUpCodes against standard HTTP status codes
Addresses reviewer feedback by enforcing strict validation on the  array. Previously, the Zod schema accepted any arbitrary number, which could allow invalid or self-defined HTTP codes (e.g., 5000) to be saved to the database.

Refs #3657
2026-06-01 13:21:30 +05:30
harsh-aghara 4e31499f78 feat(backend): add customUpCodes support for HTTP monitors
Adds a customUpCodes field to the monitor schema to allow users to specify which HTTP status codes should be treated as UP instead of DOWN. Includes core logic in HttpProvider and unit tests.

Refs #3657
2026-06-01 00:10:55 +05:30
326 changed files with 7768 additions and 5923 deletions

No files matched your search

+16 -12
View File
@@ -68,19 +68,23 @@ VITE_APP_LOG_LEVEL="debug"
### Backend Layers
```
server/src/
├── controllers/ # Route handlers (authController, monitorController, etc.)
├── service/ # Business logic
│ ├── business/ # Core monitoring logic
│ ├── infrastructure/ # Server/system utilities
│ └── system/ # App-level settings
├── api/ # HTTP layer
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
│ ├── routes/ # API route definitions
│ └── validation/ # Zod request-payload schemas
├── domain/ # Business logic + data access, one folder per entity
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
│ ├── checks/
│ ├── incidents/
│ └── … # users, teams, status-pages, notifications, etc.
├── service/ # Cross-cutting services (e.g. infrastructure/)
├── config/ # App config wiring (controllers, routes, services, envValidation)
├── db/
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
│ ├── migration/ # Database migrations (run on startup)
│ └── modules/ # Database-specific modules
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
├── routes/v1/ # API route definitions
├── validation/ # Joi input validation schemas
└── repositories/ # Data access layer
│ └── migration/ # Database migrations (run on startup)
├── templates/ # Email/notification templates
├── types/ # Shared const tuples + derived types
└── utils/ # Shared utilities
```
### Frontend Structure
+1 -1
View File
@@ -14,7 +14,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@emotion/react": "^11.13.3",
+1
View File
@@ -31,6 +31,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
customDomain: data?.customDomain ?? null,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
@@ -33,6 +33,26 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
subtitle={mongoStats.readyState.toString()}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
subtitle={mongoStats.readsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
subtitle={mongoStats.writesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.host")}
subtitle={mongoStats.host}
@@ -151,6 +151,11 @@ const CreateStatusPage = () => {
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
if (data.customDomain !== undefined && data.customDomain !== null) {
fd.append("customDomain", data.customDomain);
} else {
fd.append("customDomain", "");
}
if (data.timezone) fd.append("timezone", data.timezone);
if (data.color) fd.append("color", data.color);
fd.append("showCharts", String(data.showCharts));
@@ -284,6 +289,27 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
@@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
getStatusPagePublicUrl(statusPage),
"_blank",
"noopener,noreferrer"
);
+13 -3
View File
@@ -14,6 +14,11 @@ import {
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -67,9 +72,14 @@ const StatusPageView = () => {
const isAdmin = useIsAdmin();
const location = useLocation();
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
});
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
@@ -134,7 +144,7 @@ const StatusPageView = () => {
);
}
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
const publicUrl = getStatusPagePublicUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -99,9 +99,9 @@ export const boldStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "36px",
},
brand: {
@@ -130,7 +130,7 @@ export const boldStyles = (
hero: {
position: "relative",
borderRadius: "22px",
padding: "36px 36px 32px",
padding: { xs: "24px 20px", md: "36px 36px 32px" },
mb: "28px",
background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`,
@@ -149,13 +149,14 @@ export const boldStyles = (
},
heroTitle: {
m: 0,
fontSize: 34,
fontSize: { xs: 24, md: 34 },
fontWeight: 800,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: tokens.text,
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
gap: "10px",
},
heroCheck: (tone) => ({
@@ -171,9 +172,15 @@ export const boldStyles = (
}),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "16px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -181,16 +188,20 @@ export const boldStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "8px 18px",
padding: { xs: "8px 6px", md: "8px 18px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.12em",
letterSpacing: { xs: "0.04em", md: "0.12em" },
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -217,13 +228,21 @@ export const boldStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "24px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "24px" },
rowGap: { xs: "10px", md: "6px" },
p: "22px 28px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 700,
fontSize: 17,
letterSpacing: "-0.01em",
@@ -233,10 +252,9 @@ export const boldStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "6px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -273,6 +291,8 @@ export const boldStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 800,
padding: "6px 14px",
@@ -296,10 +316,10 @@ export const boldStyles = (
}),
heatmap: {
padding: "0 28px 22px",
padding: { xs: "0 12px 18px", md: "0 28px 22px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind) => ({
@@ -312,10 +332,10 @@ export const boldStyles = (
}),
histogram: {
padding: "0 28px",
padding: { xs: "0 12px", md: "0 28px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 48,
},
@@ -90,9 +90,9 @@ export const editorialStyles = (
},
top: {
display: "flex",
alignItems: "flex-end",
alignItems: { xs: "flex-start", md: "flex-end" },
justifyContent: "space-between",
gap: "12px",
mb: "48px",
borderBottom: `2px solid ${tokens.text}`,
pb: "18px",
@@ -119,7 +119,7 @@ export const editorialStyles = (
},
statusLine: {
fontSize: 22,
fontSize: { xs: 18, md: 22 },
fontWeight: 400,
m: 0,
mb: "8px",
@@ -144,26 +144,32 @@ export const editorialStyles = (
letterSpacing: "0.12em",
color: tokens.textMuted,
m: 0,
mb: "56px",
mb: { xs: "32px", md: "56px" },
},
chartSwitchWrap: {
display: "flex",
justifyContent: "flex-end",
justifyContent: { xs: "center", md: "flex-end" },
mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "6px 14px",
letterSpacing: { xs: "0.05em", md: "0.2em" },
padding: { xs: "6px 6px", md: "6px 14px" },
border: 0,
background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text,
@@ -188,12 +194,20 @@ export const editorialStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "baseline",
gap: "20px",
alignItems: { xs: "start", md: "baseline" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.015em",
@@ -203,14 +217,13 @@ export const editorialStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
},
pill: {
textTransform: "uppercase",
@@ -234,6 +247,8 @@ export const editorialStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
@@ -250,7 +265,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind) => ({
@@ -262,7 +277,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
gap: { xs: "1px", md: "2px" },
alignItems: "flex-end",
height: 40,
},
@@ -11,24 +11,24 @@ export const ModernHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box sx={styles.heroRow}>
<Box sx={styles.pulse(overall.tone)} />
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -17,7 +17,7 @@ export interface ModernStyles {
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
pulse: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroRow: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -117,9 +117,9 @@ export const modernStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "32px",
},
brand: {
@@ -148,11 +148,11 @@ export const modernStyles = (
hero: {
position: "relative",
borderRadius: "20px",
padding: "28px 32px",
padding: { xs: "20px", md: "28px 32px" },
mb: "24px",
display: "flex",
alignItems: "center",
gap: "20px",
flexDirection: "column",
gap: "4px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
boxShadow: cardShadow,
@@ -182,25 +182,37 @@ export const modernStyles = (
},
};
},
statusCopy: { flex: 1, minWidth: 0 },
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "14px", md: "20px" },
},
heroTitle: {
m: 0,
mb: "4px",
fontSize: 22,
flex: 1,
minWidth: 0,
fontSize: { xs: 18, md: 22 },
fontWeight: 700,
letterSpacing: "-0.02em",
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "14px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -208,16 +220,20 @@ export const modernStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "6px 16px",
padding: { xs: "6px 6px", md: "6px 16px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
letterSpacing: { xs: "0.04em", md: "0.08em" },
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -256,13 +272,21 @@ export const modernStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "20px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
p: "18px 24px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 15,
letterSpacing: "-0.005em",
@@ -272,10 +296,9 @@ export const modernStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "6px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -299,6 +322,8 @@ export const modernStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 700,
padding: "5px 12px",
@@ -319,10 +344,10 @@ export const modernStyles = (
}),
heatmap: {
padding: "0 24px 20px",
padding: { xs: "0 12px 16px", md: "0 24px 20px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind) => ({
@@ -334,10 +359,10 @@ export const modernStyles = (
}),
histogram: {
padding: "0 24px",
padding: { xs: "0 12px", md: "0 24px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 46,
},
@@ -11,24 +11,24 @@ export const RefinedHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box sx={styles.heroRow}>
<Box sx={styles.statusDot(overall.tone)} />
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -17,7 +17,7 @@ export interface RefinedStyles {
company: SxProps<Theme>;
hero: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroRow: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -102,9 +102,9 @@ export const refinedStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "28px",
},
brand: {
@@ -133,10 +133,10 @@ export const refinedStyles = (
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius,
padding: "22px 24px",
padding: { xs: "18px 20px", md: "22px 24px" },
display: "flex",
alignItems: "center",
gap: "16px",
flexDirection: "column",
gap: "4px",
boxShadow: cardShadow,
mb: "20px",
},
@@ -148,10 +148,15 @@ export const refinedStyles = (
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
flexShrink: 0,
}),
statusCopy: { flex: 1, minWidth: 0 },
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "12px", md: "16px" },
},
heroTitle: {
m: 0,
mb: "2px",
flex: 1,
minWidth: 0,
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
@@ -159,14 +164,21 @@ export const refinedStyles = (
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "12px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "8px",
background: tokens.surface,
@@ -174,11 +186,15 @@ export const refinedStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "5px 14px",
padding: { xs: "5px 6px", md: "5px 14px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
borderRadius: "5px",
@@ -207,13 +223,21 @@ export const refinedStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "16px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "16px" },
rowGap: { xs: "10px", md: "4px" },
p: "16px 20px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 14,
letterSpacing: "-0.005em",
@@ -223,10 +247,9 @@ export const refinedStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "4px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -251,6 +274,8 @@ export const refinedStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 600,
padding: "4px 10px",
@@ -261,10 +286,10 @@ export const refinedStyles = (
}),
heatmap: {
padding: "0 20px 16px",
padding: { xs: "0 10px 14px", md: "0 20px 16px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind) => ({
@@ -276,10 +301,10 @@ export const refinedStyles = (
}),
histogram: {
padding: "0 20px",
padding: { xs: "0 10px", md: "0 20px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 42,
},
@@ -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"] };
@@ -101,8 +102,9 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return (
<Box sx={styles.page}>
<Box
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
sx={styles.top}
>
<HeaderSlot
@@ -112,7 +114,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
monitorCount={monitors.length}
styles={styles}
/>
</Box>
</Stack>
<HeroSlot
statusPage={statusPage}
@@ -161,6 +163,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
@@ -169,32 +172,39 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
<Stack
direction={{ xs: "column", md: "row" }}
sx={styles.monitorMeta}
>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
<Box
component="span"
sx={{ ...styles.pill }}
>
{uptimePercentage}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Stack>
</Box>
{showInfra && (
@@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
import { type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -53,8 +55,7 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
}
};
@@ -70,7 +71,9 @@ export const StatusPagesTable = ({
content: t("pages.statusPages.table.headers.url"),
render: (row) => {
const content = row.isPublished
? `/${row.url}`
? row.customDomain
? getStatusPagePublicPath(row)
: `/${row.url}`
: t("pages.statusPages.table.unpublished");
return (
<Stack
@@ -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"),
+16
View File
@@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs";
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
<LibRoutes>
<Route
path="/"
element={<Status />}
/>
<Route
path="*"
element={<Status />}
/>
</LibRoutes>
);
}
return (
<LibRoutes>
<Route
+5
View File
@@ -23,6 +23,11 @@ export interface V8HeapStats {
export interface MongoStats {
readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string;
port: number;
dbName: string;
+1
View File
@@ -47,6 +47,7 @@ export interface StatusPage {
type: MonitorDisplayType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
+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;
+89
View File
@@ -0,0 +1,89 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
const getClientOriginProtocol = (): string => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return "https:";
}
try {
return new URL(source).protocol;
} catch {
return "https:";
}
};
const buildCustomDomainOrigin = (customDomain: string): string =>
`${getClientOriginProtocol()}//${customDomain}`;
export const getAppHostname = (): string | null => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return null;
}
try {
return new URL(source).hostname.toLowerCase();
} catch {
return null;
}
};
export const isCustomDomainHost = (): boolean => {
if (typeof window === "undefined") {
return false;
}
const appHostname = getAppHostname();
if (!appHostname) {
return false;
}
return window.location.hostname.toLowerCase() !== appHostname;
};
export const getStatusPagePublicPath = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
}): string | null => {
const query = "type=uptime&type=infrastructure";
if (options.useCustomDomain && typeof window !== "undefined") {
const domain = encodeURIComponent(window.location.hostname);
return `/status-page/resolve?domain=${domain}&${query}`;
}
if (options.url) {
return `/status-page/${options.url}?${query}`;
}
return null;
};
+23
View File
@@ -9,6 +9,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"
// field definitions.
export const statusPageStepRegistry = z.registry<{ step: number }>();
const statusPageHostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const normalizeCustomDomainInput = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return null;
}
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
};
export const statusPageSchema = z.object({
companyName: z
.string()
@@ -22,6 +39,12 @@ export const statusPageSchema = z.object({
/^[a-z0-9-]+$/,
"URL can only contain lowercase letters, numbers, and hyphens"
),
customDomain: z
.union([z.string(), z.null()])
.transform(normalizeCustomDomainInput)
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
message: "Enter a valid domain name (e.g. status.example.com)",
}),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
+12 -1
View File
@@ -975,6 +975,11 @@
},
"mongoDBStats": {
"readyState": "Ready state",
"readsPerSecond": "Reads/s",
"insertsPerSecond": "Inserts/s",
"updatesPerSecond": "Updates/s",
"deletesPerSecond": "Deletes/s",
"writesPerSecond": "Total writes/s",
"host": "Host",
"port": "Port",
"dbName": "Database name",
@@ -1509,6 +1514,11 @@
"url": {
"label": "Your status page address",
"placeholder": "my-status-page"
},
"customDomain": {
"label": "Custom domain",
"placeholder": "status.example.com",
"helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain."
}
}
},
@@ -1607,7 +1617,8 @@
},
"table": {
"headers": {
"responseTime": "Response time"
"responseTime": "Response time",
"uptime": "Uptime %"
}
},
"fallback": {
+2 -1
View File
@@ -11,7 +11,8 @@ LOG_LEVEL=debug
# Database
# --------
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
QUEUE_TYPE=superSimpleQueue
QUEUE_MODE="primary"
QUEUE_PRIMARY_PROCESSES="true"
# JWT Authentication
# ------------------
+1 -1
View File
@@ -39,7 +39,7 @@ export default [
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
"@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
},
-1
View File
@@ -8,7 +8,6 @@ const config: Config = {
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"ignore": ["node_modules/*"],
"watch": ["src/**/*.ts", "src/**/*.js", "*.json"],
"watch": ["src/**/*.ts", "src/**/*.js", "*.json", ".env"],
"ext": "ts,js,json"
}
+145 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Checkmate API",
"version": "1.0.0",
"description": "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts"
"description": "Generated from Zod validation schemas. Source of truth: server/src/api/validation/*.ts"
},
"servers": [
{
@@ -398,6 +398,12 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
@@ -7016,6 +7022,13 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
},
"default": []
},
"secret": {
"type": "string"
},
@@ -7685,6 +7698,13 @@
},
"default": []
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
},
"default": []
},
"secret": {
"type": "string"
},
@@ -8209,6 +8229,12 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
@@ -10422,6 +10448,17 @@
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"customDomain": {
"anyOf": [
{
"type": "string",
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
},
{
"nullable": true
}
]
},
"timezone": {
"type": "string"
},
@@ -10625,6 +10662,17 @@
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"customDomain": {
"anyOf": [
{
"type": "string",
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
},
{
"nullable": true
}
]
},
"timezone": {
"type": "string"
},
@@ -10894,6 +10942,102 @@
}
}
},
"/status-page/resolve": {
"get": {
"tags": ["status-page"],
"summary": "Resolve a published status page by custom domain",
"parameters": [
{
"schema": {
"anyOf": [
{
"type": "string",
"enum": ["uptime", "infrastructure"]
},
{
"type": "array",
"items": {
"type": "string",
"enum": ["uptime", "infrastructure"]
}
}
]
},
"required": true,
"name": "type",
"in": "query"
},
{
"schema": {
"type": "number",
"nullable": true
},
"required": false,
"name": "timeFrame",
"in": "query"
},
{
"schema": {
"type": "string"
},
"required": false,
"name": "domain",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"404": {
"description": "Status page not found"
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/status-page/{url}": {
"get": {
"tags": ["status-page"],
+1 -1
View File
@@ -28,7 +28,7 @@ export function getOpenApiSpec(): JsonObject {
info: {
title: "Checkmate API",
version: "1.0.0",
description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts",
description: "Generated from Zod validation schemas. Source of truth: server/src/api/validation/*.ts",
},
servers: [{ url: "/api/v1", description: "Current server" }],
tags: [
+2 -2
View File
@@ -7,7 +7,7 @@ import {
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
} from "@/api/validation/authValidation.js";
import {
getUserByIdParamValidation,
editUserByIdParamValidation,
@@ -16,7 +16,7 @@ import {
createUserBodyValidation,
editUserBodyValidation,
userResponseSchema,
} from "@/validation/userValidation.js";
} from "@/api/validation/userValidation.js";
const tags = ["auth"];
+1 -1
View File
@@ -7,7 +7,7 @@ import {
getTeamChecksQueryValidation,
deleteChecksParamValidation,
getChecksSummaryByTeamIdQueryValidation,
} from "@/validation/checkValidation.js";
} from "@/api/validation/checkValidation.js";
const tags = ["checks"];
+1 -1
View File
@@ -1,6 +1,6 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
import { getChecksParamValidation, getChecksQueryValidation } from "@/api/validation/checkValidation.js";
registry.registerPath({
method: "get",
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/api/validation/incidentValidation.js";
const tags = ["incidents"];
+1 -1
View File
@@ -1,7 +1,7 @@
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { z } from "zod";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js";
const tags = ["invite"];
+1 -1
View File
@@ -8,7 +8,7 @@ import {
deleteMaintenanceWindowByIdParamValidation,
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
} from "@/validation/maintenanceWindowValidation.js";
} from "@/api/validation/maintenanceWindowValidation.js";
const tags = ["maintenance-window"];
+2 -2
View File
@@ -16,8 +16,8 @@ import {
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
monitorResponseSchema,
} from "@/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
} from "@/api/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
const tags = ["monitors"];
+1 -1
View File
@@ -7,7 +7,7 @@ import {
getNotificationByIdParamValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
} from "@/api/validation/notificationValidation.js";
const tags = ["notifications"];
+2 -2
View File
@@ -1,7 +1,7 @@
import { registry } from "../registry.js";
import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { updateAppSettingsBodyValidation } from "@/api/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/api/validation/notificationValidation.js";
const tags = ["settings"];
+15 -1
View File
@@ -1,6 +1,11 @@
import { registry } from "../registry.js";
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
getStatusPageQueryValidation,
resolveStatusPageQueryValidation,
} from "@/api/validation/statusPageValidation.js";
const tags = ["status-page"];
@@ -33,6 +38,15 @@ registry.registerPath({
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/status-page/resolve",
tags,
summary: "Resolve a published status page by custom domain",
request: { query: resolveStatusPageQueryValidation },
responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/status-page/{url}",
+1181 -988
View File
File diff suppressed because it is too large. Load diff
+2 -5
View File
@@ -22,7 +22,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "prettier --write --ignore-unknown"
},
"keywords": [],
"author": "",
@@ -33,7 +33,6 @@
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.15.2",
"bcryptjs": "3.0.2",
"cacheable-lookup": "7.0.0",
@@ -48,12 +47,10 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"less-simple-scheduler": "2.6.0",
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
@@ -62,7 +59,6 @@
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.9.2",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -96,6 +92,7 @@
"globals": "^15.14.0",
"jest": "^30.2.0",
"lint-staged": "^16.2.7",
"mongodb-memory-server": "11.2.0",
"nodemon": "^3.1.11",
"prettier": "^3.3.3",
"prettier-plugin-ini": "1.3.0",
+269
View File
@@ -0,0 +1,269 @@
import mongoose from "mongoose";
import { MonitorModel } from "../dist/domain/monitors/monitor.model.js";
import { UserModel } from "../dist/domain/users/user.model.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 = 500;
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,
lastEvaluatedAt: 0, // evaluator watermark; without it findUnevaluatedByMonitorId gets new Date(undefined)
});
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);
});
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
import {
registrationBodyValidation,
@@ -8,7 +8,7 @@ import {
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
} from "@/api/validation/authValidation.js";
import {
editUserBodyValidation,
@@ -18,8 +18,8 @@ import {
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/userValidation.js";
import { IUserService } from "@/service/index.js";
} from "@/api/validation/userValidation.js";
import { IUserService } from "@/domain/users/user.service.js";
const SERVICE_NAME = "authController";
@@ -6,9 +6,9 @@ import {
getTeamChecksQueryValidation,
getChecksSummaryByTeamIdQueryValidation,
deleteChecksParamValidation,
} from "@/validation/checkValidation.js";
import { ICheckService } from "@/service/index.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
} from "@/api/validation/checkValidation.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { requireTeamId } from "@/api/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
@@ -1,5 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
@@ -1,4 +1,4 @@
import { IDiagnosticService } from "@/service/index.js";
import { IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "diagnosticController";
@@ -1,8 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import { getChecksParamValidation, getChecksQueryValidation } from "@/api/validation/checkValidation.js";
import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { requireTeamId } from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "geoCheckController";
@@ -1,8 +1,8 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
import { IIncidentService } from "@/domain/incidents/incident.service.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/api/validation/incidentValidation.js";
const SERVICE_NAME = "IncidentController";
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { IInviteService } from "@/service/index.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
import { IInviteService } from "@/domain/invites/invite.service.js";
const SERVICE_NAME = "inviteController";
export interface IInviteController {
@@ -7,9 +7,9 @@ import {
getMaintenanceWindowsByMonitorIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
deleteMaintenanceWindowByIdParamValidation,
} from "@/validation/maintenanceWindowValidation.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
import { IMaintenanceWindowService } from "@/service/index.js";
} from "@/api/validation/maintenanceWindowValidation.js";
import { requireTeamId } from "@/api/controllers/controllerUtils.js";
import { IMaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js";
export interface IMaintenanceWindowController {
createMaintenanceWindows: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
@@ -15,11 +15,12 @@ import {
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
bulkPauseMonitorBodyValidation,
} from "@/validation/monitorValidation.js";
} from "@/api/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { IMonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js";
const SERVICE_NAME = "monitorController";
@@ -7,11 +7,11 @@ import {
testNotificationBodyValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
} from "@/api/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
import { INotificationsService } from "@/service/index.js";
import { INotificationsService } from "@/domain/notifications/notification.service.js";
import { requireTeamId, requireUserId } from "./controllerUtils.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
const SERVICE_NAME = "NotificationController";
@@ -1,5 +1,5 @@
import { IJobQueue } from "@/service/index.js";
import { getQueueJobsQueryValidation } from "@/validation/queueValidation.js";
import { IWorker } from "@/worker/worker.interface.js";
import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "JobQueueController";
@@ -13,9 +13,9 @@ export interface IJobQueueController {
class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: IJobQueue;
constructor(jobQueue: IJobQueue) {
this.jobQueue = jobQueue;
private worker: IWorker;
constructor(worker: IWorker) {
this.worker = worker;
}
get serviceName() {
@@ -24,7 +24,7 @@ class JobQueueController implements IJobQueueController {
getMetrics = async (req: Request, res: Response, next: NextFunction) => {
try {
const metrics = await this.jobQueue.getMetrics();
const metrics = await this.worker.getMetrics();
res.status(200).json({
success: true,
msg: "Queue metrics fetched successfully",
@@ -38,7 +38,7 @@ class JobQueueController implements IJobQueueController {
getJobs = async (req: Request, res: Response, next: NextFunction) => {
try {
const pagination = getQueueJobsQueryValidation.parse(req.query);
const { jobs, count } = await this.jobQueue.getJobs(pagination);
const { jobs, count } = await this.worker.getJobs(pagination);
return res.status(200).json({
success: true,
msg: "Queue jobs fetched successfully",
@@ -52,8 +52,8 @@ class JobQueueController implements IJobQueueController {
getAllMetrics = async (req: Request, res: Response, next: NextFunction) => {
try {
const pagination = getQueueJobsQueryValidation.parse(req.query);
const { jobs, count } = await this.jobQueue.getJobs(pagination);
const metrics = await this.jobQueue.getMetrics();
const { jobs, count } = await this.worker.getJobs(pagination);
const metrics = await this.worker.getMetrics();
return res.status(200).json({
success: true,
msg: "Queue metrics fetched successfully",
@@ -66,7 +66,7 @@ class JobQueueController implements IJobQueueController {
flushQueue = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.jobQueue.flushQueues();
const result = await this.worker.flushQueues();
return res.status(200).json({
success: true,
msg: "Queue flushed successfully",
@@ -1,9 +1,10 @@
import { Request, Response, NextFunction } from "express";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { updateAppSettingsBodyValidation } from "@/api/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/api/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
import { IEmailService, ISettingsService } from "@/service/index.js";
import { Settings } from "@/types/settings.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { IEmailService } from "@/service/emailService.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
const SERVICE_NAME = "SettingsController";
@@ -5,13 +5,16 @@ import {
getStatusPageParamValidation,
getStatusPageQueryValidation,
imageValidation,
} from "@/validation/statusPageValidation.js";
resolveStatusPageQueryValidation,
} from "@/api/validation/statusPageValidation.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
import { IStatusPageService } from "@/domain/status-pages/status-page.service.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { NormalizeData } from "@/utils/dataUtils.js";
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
import type { StatusPage } from "@/domain/status-pages/status-page.type.js";
const SERVICE_NAME = "statusPageController";
@@ -20,6 +23,7 @@ export interface IStatusPageController {
createStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
updateStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
@@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController {
return StatusPageController.SERVICE_NAME;
}
private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => {
if (!statusPage.isPublished) {
const teamId = requireTeamId(req?.user?.teamId);
if (statusPage.teamId !== teamId) {
throw new AppError({ message: "Forbidden", status: 403 });
}
}
const settings = await this.settingsService.getDBSettings();
const showURL = settings.showURL;
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
const sortedMonitors = [...monitors].sort((a, b) => {
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const normalizedMonitors = sortedMonitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, port, secret, notifications, ...rest } = monitor;
return { ...rest, checks: normalizedChecks };
}
return { ...monitor, checks: normalizedChecks };
});
return {
statusPage,
monitors: normalizedMonitors,
};
};
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
createStatusPageBodyValidation.parse(req.body);
@@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController {
}
const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string);
const data = await this.buildStatusPagePayload(statusPage, req);
if (!statusPage.isPublished) {
const teamId = requireTeamId(req?.user?.teamId);
if (statusPage.teamId !== teamId) {
throw new AppError({ message: "Forbidden", status: 403 });
}
}
const settings = await this.settingsService.getDBSettings();
const showURL = settings.showURL;
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
// Sort monitors according to the order in statusPage.monitors
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
const sortedMonitors = [...monitors].sort((a, b) => {
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const normalizedMonitors = sortedMonitors.map((monitor) => {
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
if (!showURL) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, port, secret, notifications, ...rest } = monitor;
return { ...rest, checks: normalizedChecks };
}
return { ...monitor, checks: normalizedChecks };
});
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: {
statusPage,
monitors: normalizedMonitors,
},
data,
});
} catch (error) {
next(error);
}
};
resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => {
try {
resolveStatusPageQueryValidation.parse(req.query);
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
if (!domain) {
throw new AppError({ message: "Domain is required", status: 400 });
}
const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain);
if (!statusPage.isPublished) {
throw new AppError({ message: "Status page not found", status: 404 });
}
const data = await this.buildStatusPagePayload(statusPage, req);
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data,
});
} catch (error) {
next(error);
}
};
getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
@@ -1,4 +1,4 @@
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/domain/status-pages/status-page.type.js";
/**
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { ITagsService } from "@/service/index.js";
import { ITagsService } from "@/domain/tags/tag.service.js";
import { requireTeamId } from "./controllerUtils.js";
import {
createTagBodyValidation,
@@ -7,7 +7,7 @@ import {
getTagByIdParamValidation,
editTagParamValidation,
deleteTagParamValidation,
} from "@/validation/index.js";
} from "@/api/validation/index.js";
export interface ITagsController {
createTag: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
@@ -1,11 +1,15 @@
import type { NextFunction, Request, Response } from "express";
import { MulterError } from "multer";
import { logger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
// Multer throws MulterError, send 413 Payload Too Large / 400
const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400);
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
const status = error instanceof AppError ? error.status || 500 : 500;
const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500;
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
const service = error instanceof AppError ? error.service : "unknownService";
const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService";
const method = error instanceof AppError ? error.method : "unknownMethod";
logger.error({
message: message,
@@ -1,7 +1,7 @@
import type { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "allowedRoles";
import { AppError } from "@/utils/AppError.js";
import type { UserRole } from "@/types/index.js";
import type { UserRole } from "@/domain/users/user.type.js";
const isAllowed = (allowedRoles: UserRole[]) => {
return (req: Request, res: Response, next: NextFunction) => {
@@ -0,0 +1,68 @@
import type { CorsOptions } from "cors";
import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000;
const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000;
type CustomDomainCorsCacheEntry = {
allowed: boolean;
expiresAt: number;
};
const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number =>
allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS;
export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => {
const cache = new Map<string, CustomDomainCorsCacheEntry>();
const cacheOriginResult = (customDomain: string, allowed: boolean) => {
cache.set(customDomain, {
allowed,
expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed),
});
};
return (origin, callback) => {
if (!origin) {
callback(null, true);
return;
}
if (origin === clientHost) {
callback(null, true);
return;
}
let customDomain: string | null = null;
try {
customDomain = normalizeStatusPageDomain(new URL(origin).hostname);
} catch {
callback(null, false);
return;
}
if (!customDomain) {
callback(null, false);
return;
}
const cached = cache.get(customDomain);
if (cached && cached.expiresAt > Date.now()) {
callback(null, cached.allowed ? origin : false);
return;
}
statusPagesRepository
.findByCustomDomain(customDomain)
.then((statusPage) => {
const allowed = statusPage.isPublished;
cacheOriginResult(customDomain, allowed);
callback(null, allowed ? origin : false);
})
.catch(() => {
cacheOriginResult(customDomain, false);
callback(null, false);
});
};
};
@@ -0,0 +1,24 @@
const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page";
export const isPublicStatusPageApiPath = (method: string, path: string): boolean => {
if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) {
return false;
}
if (method !== "GET" && method !== "OPTIONS") {
return false;
}
const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? "";
const slug = subPath.replace(/^\//, "");
if (slug === "resolve") {
return true;
}
if (!slug || slug === "team" || slug.includes("/")) {
return false;
}
return true;
};
+27
View File
@@ -0,0 +1,27 @@
import multer from "multer";
import { AppError } from "@/utils/AppError.js";
import { ImageMimeTypes, MAX_IMAGE_SIZE_BYTES } from "@/types/upload.js";
// Reusable multer instance for handling image uploads
const imageUpload = multer({
limits: {
fileSize: MAX_IMAGE_SIZE_BYTES,
files: 1,
},
fileFilter: (_req, file, cb) => {
if (!(ImageMimeTypes as readonly string[]).includes(file.mimetype)) {
cb(
new AppError({
status: 415,
message: "File must be a valid image (jpeg, jpg, or png)",
service: "uploadMiddleware",
method: "fileFilter",
})
);
return;
}
cb(null, true);
},
});
export { imageUpload };
@@ -2,8 +2,8 @@ import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { AppError } from "@/utils/AppError.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
import type { User } from "@/types/user.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import type { User } from "@/domain/users/user.type.js";
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
@@ -1,5 +1,5 @@
import { NextFunction, Request, RequestHandler, Response } from "express";
import { IStatusPagesRepository } from "@/repositories/index.js";
import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import { AppError } from "@/utils/AppError.js";
export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository, verifyJWT: RequestHandler) => {
+12 -8
View File
@@ -1,9 +1,7 @@
import { Router, RequestHandler } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import multer from "multer";
import { IAuthController } from "@/controllers/authController.js";
const upload = multer();
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { imageUpload } from "@/api/middleware/upload.js";
import { IAuthController } from "@/api/controllers/authController.js";
class AuthRoutes {
private router: Router;
@@ -16,7 +14,7 @@ class AuthRoutes {
}
initRoutes(verifyJWT: RequestHandler) {
this.router.post("/register", upload.single("profileImage"), this.authController.registerUser);
this.router.post("/register", imageUpload.single("profileImage"), this.authController.registerUser);
this.router.post("/login", this.authController.loginUser);
this.router.post("/recovery/request", this.authController.requestRecovery);
@@ -26,13 +24,19 @@ class AuthRoutes {
this.router.get("/users/superadmin", this.authController.checkSuperadminExists);
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), imageUpload.single("profileImage"), this.authController.createUser);
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
this.router.patch(
"/user",
verifyJWT,
imageUpload.single("profileImage"),
isAllowed(["admin", "superadmin", "user"]),
this.authController.editUser
);
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { ICheckController } from "@/controllers/checkController.js";
import { ICheckController } from "@/api/controllers/checkController.js";
class CheckRoutes {
private router: Router;
@@ -1,6 +1,6 @@
import { RequestHandler, Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IDiagnosticController } from "@/controllers/diagnosticController.js";
import { IDiagnosticController } from "@/api/controllers/diagnosticController.js";
class DiagnosticRoutes {
private router: Router;
@@ -1,4 +1,4 @@
import { IGeoCheckController } from "@/controllers/geoCheckController.js";
import { IGeoCheckController } from "@/api/controllers/geoCheckController.js";
import { Router } from "express";
class GeoCheckRoutes {
@@ -1,6 +1,6 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IIncidentController } from "@/controllers/incidentController.js";
import { IIncidentController } from "@/api/controllers/incidentController.js";
class IncidentRoutes {
private router: Router;
+1 -1
View File
@@ -1,6 +1,6 @@
import { RequestHandler, Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IInviteController } from "@/controllers/inviteController.js";
import { IInviteController } from "@/api/controllers/inviteController.js";
class InviteRoutes {
private router: Router;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Router } from "express";
import { isAllowed } from "@/middleware/isAllowed.js";
import { ILogController } from "@/controllers/logController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { ILogController } from "@/api/controllers/logController.js";
class LogRoutes {
private router: Router;
private logController: ILogController;
@@ -1,4 +1,4 @@
import { IMaintenanceWindowController } from "@/controllers/maintenanceWindowController.js";
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
import { Router } from "express";
class MaintenanceWindowRoutes {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Router } from "express";
import { isAllowed } from "@/middleware/isAllowed.js";
import { IMonitorController } from "@/controllers/monitorController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { IMonitorController } from "@/api/controllers/monitorController.js";
class MonitorRoutes {
private router: Router;
@@ -1,4 +1,4 @@
import { INotificationController } from "@/controllers/notificationController.js";
import { INotificationController } from "@/api/controllers/notificationController.js";
import { Router } from "express";
class NotificationRoutes {
private router: Router;
+2 -2
View File
@@ -1,6 +1,6 @@
import { Router } from "express";
import { isAllowed } from "@/middleware/isAllowed.js";
import { IJobQueueController } from "@/controllers/queueController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { IJobQueueController } from "@/api/controllers/queueController.js";
class QueueRoutes {
private router: Router;
private queueController: IJobQueueController;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { ISettingsController } from "@/controllers/settingsController.js";
import { ISettingsController } from "@/api/controllers/settingsController.js";
class SettingsRoutes {
private router: Router;
@@ -1,7 +1,6 @@
import { IStatusPageController } from "@/controllers/statusPageController.js";
import { IStatusPageController } from "@/api/controllers/statusPageController.js";
import { RequestHandler, Router } from "express";
import multer from "multer";
const upload = multer();
import { imageUpload } from "@/api/middleware/upload.js";
class StatusPageRoutes {
private router: Router;
@@ -16,9 +15,10 @@ class StatusPageRoutes {
initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId);
this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain);
this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl);
this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage);
}
@@ -1,4 +1,4 @@
import { ITagsController } from "@/controllers/tagController.js";
import { ITagsController } from "@/api/controllers/tagController.js";
import { Router } from "express";
class TagRoutes {
private router: Router;
@@ -1,6 +1,6 @@
import { z } from "zod";
import { passwordPattern, nameValidation, lowercaseEmailValidation } from "./shared.js";
import { UserRoles } from "@/types/user.js";
import { UserRoles } from "@/domain/users/user.type.js";
//****************************************
// Auth Validations
@@ -1,7 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorTypes } from "@/types/monitor.js";
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import { MonitorTypes } from "@/domain/monitors/monitor.types.js";
//****************************************
// Check Validations
@@ -9,7 +9,7 @@
export * from "./shared.js";
// Environment validation
export * from "./envValidation.js";
export * from "@/config/envValidation.js";
// Domain-specific validations
export * from "./authValidation.js";
@@ -1,6 +1,6 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { DurationUnits } from "@/types/maintenanceWindow.js";
import { DurationUnits } from "@/domain/maintenance-windows/maintenance-window.type.js";
const dateToString = z.coerce.date().transform((d) => d.toISOString());
@@ -1,7 +1,16 @@
import { z } from "zod";
import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js";
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
import {
DnsRecordTypes,
HttpStatusCodeSet,
MonitorMatchMethods,
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
} from "@/domain/monitors/monitor.types.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -81,6 +90,7 @@ export const createMonitorBodyValidation = z
tempAlertThreshold: z.number().optional(),
notifications: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -110,6 +120,7 @@ export const editMonitorBodyValidation = z
interval: z.number().optional(),
notifications: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
useAdvancedMatching: z.boolean().optional(),
@@ -180,6 +191,7 @@ const importedMonitorSchema = z
uptimePercentage: z.number().optional(),
notifications: z.array(z.string()).default([]),
tags: z.array(z.string()).default([]),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
cpuAlertThreshold: z.number().default(100),
cpuAlertCounter: z.number().default(5),
@@ -241,6 +253,7 @@ export const monitorResponseSchema = z
matchMethod: z.enum(MonitorMatchMethods).optional(),
notifications: z.array(z.string()),
tags: z.array(z.string()),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
@@ -259,5 +272,6 @@ export const monitorResponseSchema = z
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
lastEvaluatedAt: z.number(),
})
.passthrough();
@@ -1,4 +1,4 @@
import { CHECK_TTL_SENTINEL } from "@/types/check.js";
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
import { z } from "zod";
//****************************************
@@ -1,5 +1,5 @@
import { z } from "zod";
import { type UserRole } from "@/types/user.js";
import { type UserRole } from "@/domain/users/user.type.js";
export const passwordPattern =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/;
@@ -1,6 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js";
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
//****************************************
// Status Page Validations
@@ -15,6 +16,20 @@ export const getStatusPageQueryValidation = z.object({
timeFrame: z.coerce.number().optional(),
});
export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({
domain: z.string().optional(),
});
const customDomainValidation = z.preprocess(
(val) => {
if (val === undefined || val === null) {
return undefined;
}
return normalizeStatusPageDomain(String(val));
},
z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional()
);
export const createStatusPageBodyValidation = z
.object({
type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
@@ -22,6 +37,7 @@ export const createStatusPageBodyValidation = z
url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
message: "URL can only contain letters, numbers, underscores, and hyphens",
}),
customDomain: customDomainValidation,
timezone: z.string().optional(),
color: z.string().optional(),
monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"),
@@ -1,5 +1,5 @@
import { z } from "zod";
import { UserRoles } from "@/types/user.js";
import { UserRoles } from "@/domain/users/user.type.js";
import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js";
//****************************************
+21 -13
View File
@@ -1,17 +1,19 @@
import express from "express";
import path from "path";
import cors from "cors";
import cors, { type CorsOptions } from "cors";
import helmet from "helmet";
import compression from "compression";
import cookieParser from "cookie-parser";
import swaggerUi, { type JsonObject } from "swagger-ui-express";
import { handleErrors } from "@/middleware/handleErrors.js";
import { generalApiLimiter } from "@/middleware/rateLimiter.js";
import { sanitizeBody, sanitizeQuery } from "@/middleware/sanitization.js";
import { handleErrors } from "@/api/middleware/handleErrors.js";
import { generalApiLimiter } from "@/api/middleware/rateLimiter.js";
import { sanitizeBody, sanitizeQuery } from "@/api/middleware/sanitization.js";
import { setupRoutes } from "@/config/routes.js";
import { InitializedServices } from "@/config/services.js";
import { InitializedControllers } from "@/config/controllers.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
export const createApp = ({
services,
@@ -28,17 +30,23 @@ export const createApp = ({
}) => {
const allowedOrigin = envSettings.clientHost;
const app = express();
const defaultCorsOptions: CorsOptions = {
origin: allowedOrigin,
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
credentials: true,
};
const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository);
app.use(generalApiLimiter);
app.use(
cors({
origin: allowedOrigin,
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
credentials: true,
})
);
app.use((req, res, next) => {
const corsOptions = isPublicStatusPageApiPath(req.method, req.path)
? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin }
: defaultCorsOptions;
return cors(corsOptions)(req, res, next);
});
app.use(express.static(frontendPath));
+15 -15
View File
@@ -1,17 +1,17 @@
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";
import LogController from "../controllers/logController.js";
import StatusPageController from "../controllers/statusPageController.js";
import NotificationController from "../controllers/notificationController.js";
import TagsController from "../controllers/tagController.js";
import DiagnosticController from "../controllers/diagnosticController.js";
import IncidentController from "../controllers/incidentController.js";
import MonitorController from "../api/controllers/monitorController.js";
import AuthController from "../api/controllers/authController.js";
import SettingsController from "../api/controllers/settingsController.js";
import CheckController from "../api/controllers/checkController.js";
import GeoCheckController from "../api/controllers/geoCheckController.js";
import InviteController from "../api/controllers/inviteController.js";
import MaintenanceWindowController from "../api/controllers/maintenanceWindowController.js";
import QueueController from "../api/controllers/queueController.js";
import LogController from "../api/controllers/logController.js";
import StatusPageController from "../api/controllers/statusPageController.js";
import NotificationController from "../api/controllers/notificationController.js";
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import type { InitializedServices } from "@/config/services.js";
export interface InitializedControllers {
@@ -39,7 +39,7 @@ export const initializeControllers = (services: InitializedServices): Initialize
geoCheckController: new GeoCheckController(services.geoChecksService),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
queueController: new QueueController(services.worker),
logController: new LogController(services.logger),
statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService),
notificationController: new NotificationController(services.notificationsService, services.monitorsRepository),
@@ -1,6 +1,6 @@
import { z } from "zod";
import { DbTypes, QueueTypes, QueueModes } from "@/types/settings.js";
import { booleanCoercion } from "./shared.js";
import { DbTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js";
import { booleanCoercion } from "@/api/validation/shared.js";
const envSchema = z.object({
// Server Configuration
@@ -12,8 +12,8 @@ const envSchema = z.object({
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
DB_TYPE: z.enum(DbTypes).default("mongodb"),
QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"),
QUEUE_MODE: z.enum(QueueModes).default("primary"),
QUEUE_PRIMARY_PROCESSES: booleanCoercion.default(true),
// JWT Authentication
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
+17 -17
View File
@@ -1,25 +1,25 @@
import type { Application } from "express";
import { createVerifyJWT } from "../middleware/verifyJWT.js";
import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js";
import { authApiLimiter } from "../middleware/rateLimiter.js";
import { createVerifyJWT } from "../api/middleware/verifyJWT.js";
import { createVerifyStatusPageAccess } from "../api/middleware/verifyStatusPageAccess.js";
import { authApiLimiter } from "../api/middleware/rateLimiter.js";
import type { InitializedControllers } from "./controllers.js";
import type { InitializedServices } from "./services.js";
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";
import QueueRoutes from "../routes/queueRoute.js";
import LogRoutes from "../routes/logRoutes.js";
import DiagnosticRoutes from "../routes/diagnosticRoute.js";
import NotificationRoutes from "../routes/notificationRoute.js";
import TagRoutes from "../routes/tagRoutes.js";
import AuthRoutes from "../api/routes/authRoute.js";
import InviteRoutes from "../api/routes/inviteRoute.js";
import MonitorRoutes from "../api/routes/monitorRoute.js";
import CheckRoutes from "../api/routes/checkRoute.js";
import GeoCheckRoutes from "../api/routes/geoCheckRoutes.js";
import SettingsRoutes from "../api/routes/settingsRoute.js";
import MaintenanceWindowRoutes from "../api/routes/maintenanceWindowRoute.js";
import StatusPageRoutes from "../api/routes/statusPageRoute.js";
import QueueRoutes from "../api/routes/queueRoute.js";
import LogRoutes from "../api/routes/logRoutes.js";
import DiagnosticRoutes from "../api/routes/diagnosticRoute.js";
import NotificationRoutes from "../api/routes/notificationRoute.js";
import TagRoutes from "../api/routes/tagRoutes.js";
import IncidentRoutes from "../routes/incidentRoute.js";
import IncidentRoutes from "../api/routes/incidentRoute.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => {
const verifyJWT = createVerifyJWT(services.settingsService);
+130 -187
View File
@@ -1,75 +1,53 @@
import { Mongoose } from "mongoose";
import MongoDB from "../db/MongoDB.js";
import { IDb } from "@/db/IDb.js";
import {
// Service classes
NetworkService,
EmailService,
BufferService,
GlobalPingService,
LessSimpleQueue,
SuperSimpleQueue,
QueueHelper,
NotificationsService,
TagsService,
StatusService,
NotificationMessageBuilder,
MonitorService,
StatusPageService,
UserService,
CheckService,
GeoChecksService,
DiagnosticService,
InviteService,
MaintenanceWindowService,
IncidentService,
// Notification providers
WebhookProvider,
SlackProvider,
EmailProvider,
DiscordProvider,
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
PushoverProvider,
TwilioProvider,
NtfyProvider,
// Interfaces
INetworkService,
IEmailService,
IBufferService,
IJobQueue,
INotificationsService,
ITagsService,
IStatusService,
IMonitorService,
IUserService,
ICheckService,
IGeoChecksService,
IDiagnosticService,
IInviteService,
IMaintenanceWindowService,
IStatusPageService,
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
SettingsService,
EnvConfig,
} from "@/service/index.js";
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import MongoDB from "../db/db.mongo.js";
import { IDb } from "@/db/db.interface.js";
import { EnvConfig, ISettingsService, SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { CheckService, ICheckService } from "@/domain/checks/check.service.js";
import { DiagnosticService, IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js";
import { GeoChecksService, IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
import { IInviteService, InviteService } from "@/domain/invites/invite.service.js";
import { IMaintenanceWindowService, MaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js";
import { IMonitorService, MonitorService } from "@/domain/monitors/monitor.service.js";
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
import { DiscordProvider } from "@/domain/notifications/providers/discord.js";
import { EmailProvider } from "@/domain/notifications/providers/email.js";
import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
import { PagerDutyProvider } from "@/domain/notifications/providers/pagerduty.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
import { SlackProvider } from "@/domain/notifications/providers/slack.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { WebhookProvider } from "@/domain/notifications/providers/webhook.js";
import { IStatusPageService, StatusPageService } from "@/domain/status-pages/status-page.service.js";
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { BufferService, IBufferService } from "@/service/bufferService.js";
import { EmailService, IEmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
import { IWorker } from "@/worker/worker.interface.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
// Network providers
import { PingProvider } from "@/service/infrastructure/network/PingProvider.js";
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js";
import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js";
import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
// Third-party
import { Resolver } from "dns/promises";
@@ -93,40 +71,44 @@ import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
// Repositories
import {
MongoMonitorsRepository,
MongoChecksRepository,
MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
MongoInvitesRepository,
MongoRecoveryTokensRepository,
MongoNotificationsRepository,
MongoTagsRepository,
MongoIncidentsRepository,
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
MongoQueueWorkersRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
IInvitesRepository,
IRecoveryTokensRepository,
ISettingsRepository,
INotificationsRepository,
ITagsRepository,
IIncidentsRepository,
ITeamsRepository,
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js";
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js";
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import MongoMaintenanceWindowsRepository from "@/domain/maintenance-windows/maintenance-window.repository.mongo.js";
import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js";
import MongoMonitorStatsRepository from "@/domain/monitor-stats/monitor-stats.repository.mongo.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import MongoMonitorsRepository from "@/domain/monitors/monitor.repository.mongo.js";
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
import MongoNotificationsRepository from "@/domain/notifications/notification.repository.mongo.js";
import { IRecoveryTokensRepository } from "@/domain/recovery-tokens/recovery-token.repository.interface.js";
import MongoRecoveryTokensRepository from "@/domain/recovery-tokens/recovery-token.repository.mongo.js";
import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import MongoStatusPagesRepository from "@/domain/status-pages/status-page-repository.mongo.js";
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import { type QueueMode } from "@/types/settings.js";
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { CheckPipeline, GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
import { CheckProducer } from "@/worker/worker.check-producer.js";
import { CheckEvaluator } from "@/worker/worker.check-evaluator.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
import MongoQueueWorkersRepository from "@/domain/queue-workers/queue-worker.repository.mongo.js";
export type InitializedServices = {
settingsService: ISettingsService;
@@ -135,7 +117,7 @@ export type InitializedServices = {
emailService: IEmailService;
bufferService: IBufferService;
statusService: IStatusService;
jobQueue: IJobQueue;
worker: IWorker;
userService: IUserService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
@@ -171,12 +153,10 @@ export const initializeServices = async ({
logger,
envSettings,
settingsService,
queueMode = "primary",
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
queueMode?: QueueMode;
}): Promise<InitializedServices> => {
// Create DB
@@ -184,58 +164,6 @@ export const initializeServices = async ({
db = new MongoDB(logger, envSettings);
await db.connect();
// ** NOTE **
// const dbType = envSettings.dbType;
// DB type has been fixed to MongoDB for now
// if (dbType === "mongodb") {
// db = new MongoDB(logger, envSettings);
// }
// if (!db) {
// throw new AppError({ message: "Unsupported database type", status: 500 });
// }
// await db.connect();
// Repositories
// ** NOTE **
// DB Type fixed to MongoDB for now
// let monitorsRepository: IMonitorsRepository;
// let checksRepository: IChecksRepository;
// let geoChecksRepository: IGeoChecksRepository;
// let monitorStatsRepository: IMonitorStatsRepository;
// let statusPagesRepository: IStatusPagesRepository;
// let usersRepository: IUsersRepository;
// let invitesRepository: IInvitesRepository;
// let recoveryTokensRepository: IRecoveryTokensRepository;
// let settingsRepository: ISettingsRepository;
// let notificationsRepository: INotificationsRepository;
// let tagsRepository: ITagsRepository;
// let incidentsRepository: IIncidentsRepository;
// let teamsRepository: ITeamsRepository;
// let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
// if (dbType === "mongodb") {
// monitorsRepository = new MongoMonitorsRepository();
// checksRepository = new MongoChecksRepository(logger);
// geoChecksRepository = new MongoGeoChecksRepository(logger);
// monitorStatsRepository = new MongoMonitorStatsRepository();
// statusPagesRepository = new MongoStatusPagesRepository();
// usersRepository = new MongoUsersRepository();
// invitesRepository = new MongoInvitesRepository();
// recoveryTokensRepository = new MongoRecoveryTokensRepository();
// settingsRepository = new MongoSettingsRepository();
// notificationsRepository = new MongoNotificationsRepository();
// tagsRepository = new MongoTagsRepository();
// incidentsRepository = new MongoIncidentsRepository();
// teamsRepository = new MongoTeamsRepository();
// maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// } else {
// throw new AppError({ message: "Unsupported database type", status: 500 });
// }
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const geoChecksRepository = new MongoGeoChecksRepository(logger);
@@ -250,6 +178,9 @@ export const initializeServices = async ({
const incidentsRepository = new MongoIncidentsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
// One identity per process
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
// Inject settings repository into settings service (now that DB is connected)
@@ -296,7 +227,7 @@ export const initializeServices = async ({
monitorsRepository,
});
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
@@ -334,42 +265,53 @@ export const initializeServices = async ({
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const queueHelper = new QueueHelper(
const monitorStatusPolicy = new MonitorStatusPolicy();
// reactors and dispatcher
const notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
// Check producer/evaluator
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
// pipelines
const checkPipeline = new CheckPipeline(checkProducer, checkEvaluator);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
const workerHelper = new WorkerHelper(
logger,
networkService,
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
monitorsRepository,
jobsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksService,
geoChecksRepository
geoChecksRepository,
reactorDispatcher,
checkPipeline,
geoCheckPipeline
);
if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") {
throw new AppError({
message: `Worker mode requires QUEUE_TYPE="lessSimpleQueue" got ${envSettings.queueType}`,
status: 500,
service: "config services",
method: "initializeServices",
});
}
let jobQueue: IJobQueue;
switch (envSettings.queueType) {
case "lessSimpleQueue":
jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode);
break;
default:
jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository);
}
const worker = await DBQueueWorker.create(
logger,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
reactorDispatcher,
workerHelper,
queueWorkersRepository,
envSettings.queueMode,
envSettings.queuePrimaryProcesses,
workerId
);
// Business services
const userService = new UserService({
@@ -378,7 +320,7 @@ export const initializeServices = async ({
settingsService,
logger,
jwt,
jobQueue,
worker,
monitorsRepository,
usersRepository,
invitesRepository,
@@ -396,9 +338,10 @@ export const initializeServices = async ({
const maintenanceWindowService = new MaintenanceWindowService({
monitorsRepository,
maintenanceWindowsRepository,
jobsRepository,
});
const monitorService = new MonitorService({
jobQueue,
worker,
logger,
games,
monitorsRepository,
@@ -418,7 +361,7 @@ export const initializeServices = async ({
emailService,
bufferService,
statusService,
jobQueue,
worker,
userService,
checkService,
geoChecksService,
File renamed without changes.
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more