Merge pull request #3747 from bluewave-labs/develop

develop -> master
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-07-02 10:18:55 -07:00
commit 51149d1dd4
403 files changed
+11339 -6822

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
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.1.0
appVersion: "2.3"
version: 0.2.0
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+142 -12
View File
@@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml
Edit `values.yaml` to update:
- `client.ingress.host` and `server.ingress.host` with your domain names
- `server.protocol` (usually http or https)
- `client.ingress.host` and `api.ingress.host` with your domain names
- `api.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Add: `persistence.mongo.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
@@ -32,7 +31,7 @@ Edit `values.yaml` to update:
```bash
helm install checkmate ./charts/helm/checkmate
```
This will deploy the client, server, MongoDB, and Redis components.
This will deploy the client, api, and MongoDB components.
### 4. Verify the deployment
Check pods and services:
@@ -43,6 +42,137 @@ kubectl get svc
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
## Upgrading from a previous release
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
Read this before running `helm upgrade` on an existing install.
### `server.*` → `api.*` (values rename)
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
don't mix them.
### Resource renames cause a replace, not a rename
The Kubernetes objects are renamed alongside the values:
| Old object | New object |
| --- | --- |
| Deployment/Service `checkmate-server` | `checkmate-api` |
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
### Redis removed
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
keys in your values file are harmless (ignored).
### New secret defaults
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
block and merge into your install unless you override them. Set them explicitly if you relied on
different values (e.g. a non-production `NODE_ENV`).
### Image tags follow the chart's `appVersion`
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
```
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
`api.image`) unless you set `worker.tag` / `worker.image`.
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
let it default to `appVersion`) for reproducible, rollback-able deploys.
## Scaling: the worker tier & autoscaling
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
horizontally-scalable worker tier.
### Topology
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
| --- | --- | --- |
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
and worker tier can never both process (or both ignore) the queue.
### Enabling the worker tier
```yaml
worker:
enabled: true
replicas: 2 # used only when autoscaling.enabled = false
```
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
> checks.
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
> this; full failover to the API would need leader election (not supported).
### Autoscaling with KEDA
Backlog-driven autoscaling is **off by default** and requires the
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
```yaml
worker:
enabled: true
autoscaling:
enabled: true
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
maxReplicaCount: 10
backlogPerReplica: 50 # target due-job backlog per worker
mongo:
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
```
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
backlog → never scales up).
#### Fallback: Prometheus-adapter HPA (not shipped)
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
`now` in code on this path, so the query coercion is unnecessary.
## Enabling TLS/HTTPS with cert-manager
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
@@ -68,7 +198,7 @@ client:
enabled: true
secretName: checkmate-client-tls
server:
api:
protocol: https
ingress:
enabled: true
@@ -78,7 +208,7 @@ server:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: true
secretName: checkmate-server-tls
secretName: checkmate-api-tls
```
### Alternative: Using --set flags
@@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
```bash
helm install checkmate ./charts/helm/checkmate \
--set client.protocol=https \
--set server.protocol=https \
--set api.protocol=https \
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set client.ingress.tls.enabled=true \
--set client.ingress.tls.secretName=checkmate-client-tls \
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set server.ingress.tls.enabled=true \
--set server.ingress.tls.secretName=checkmate-server-tls
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set api.ingress.tls.enabled=true \
--set api.ingress.tls.secretName=checkmate-api-tls
```
### Verification
@@ -107,7 +237,7 @@ kubectl get certificates
# Check certificate details
kubectl describe certificate checkmate-client-tls
kubectl describe certificate checkmate-server-tls
kubectl describe certificate checkmate-api-tls
# Verify the secrets were created
kubectl get secrets | grep checkmate-tls
@@ -0,0 +1,35 @@
{{- /*
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
{{- $api := include "checkmate.api" . | fromYaml -}}
then reference `$api.*` instead of `.Values.api.*`.
*/}}
{{- define "checkmate.api" -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}}
{{- /*
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
*/}}
{{- define "checkmate.image" -}}
{{- $lastSegment := .image | splitList "/" | last -}}
{{- if contains ":" $lastSegment -}}
{{- .image -}}
{{- else -}}
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,65 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,11 +1,12 @@
{{- if .Values.server.ingress.enabled }}
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-server-ingress
{{- if .Values.server.ingress.annotations }}
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
annotations:
{{- range $key, $value := .Values.server.ingress.annotations }}
{{- range $key, $value := $api.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -17,22 +18,22 @@ metadata:
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ .Values.server.ingress.className }}
{{- if .Values.server.ingress.tls.enabled }}
ingressClassName: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.server.ingress.host }}
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.server.ingress.host }}
- host: {{ $api.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-server
name: checkmate-api
port:
number: {{ .Values.server.port }}
number: {{ $api.port }}
{{- end }}
@@ -0,0 +1,10 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,3 +1,4 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -22,12 +23,13 @@ spec:
{{- end }}
containers:
- name: client
image: {{ .Values.client.image }}
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
ports:
- containerPort: {{ .Values.client.port }}
env:
- name: UPTIME_APP_API_BASE_URL
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
@@ -41,4 +43,4 @@ spec:
volumes:
- name: config-volume
configMap:
name: checkmate-server-nginx-cm
name: checkmate-client-nginx-cm
@@ -1,7 +1,8 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
kind: ConfigMap
metadata:
name: checkmate-server-nginx-cm
name: checkmate-client-nginx-cm
data:
default.conf: |
server {
@@ -22,7 +23,7 @@ data:
}
# location /api/ {
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
@@ -31,7 +32,7 @@ data:
# }
location /api-docs/ {
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -22,7 +22,8 @@ spec:
{{- end }}
containers:
- name: mongodb
image: {{ .Values.mongodb.image }}
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }}
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
ports:
- containerPort: {{ .Values.mongodb.port }}
command: ["mongod", "--quiet", "--bind_ip_all"]
@@ -37,7 +38,9 @@ spec:
- metadata:
name: checkmate-mongo-persistent-storage
spec:
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
{{- with .Values.persistence.mongo.storageClass }}
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
{{- end }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
+20 -14
View File
@@ -1,14 +1,15 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- if eq $api.ingress.host "change_me" }}
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $serverProtocol := .Values.server.protocol }}
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
{{- fail "server.protocol must be either 'http' or 'https'" }}
{{- $apiProtocol := $api.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
@@ -21,28 +22,33 @@
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
{{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -1,11 +0,0 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: checkmate-redis
spec:
selector:
app: checkmate-redis
ports:
- port: {{ .Values.redis.port }}
{{- end }}
@@ -1,33 +0,0 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: checkmate-redis
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-redis
template:
metadata:
labels:
app: checkmate-redis
spec:
containers:
- name: redis
image: {{ .Values.redis.image }}
ports:
- containerPort: {{ .Values.redis.port }}
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: checkmate-redis-persistent-storage
spec:
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
{{- end }}
@@ -4,6 +4,14 @@
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
{{- end }}
{{- /*
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
the one connection string resolves from every namespace. External/already-qualified hosts are not
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
*/}}
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
apiVersion: v1
kind: Secret
metadata:
@@ -11,5 +19,9 @@ metadata:
type: Opaque
stringData:
{{- range $key, $value := $secrets }}
{{- if eq $key "DB_CONNECTION_STRING" }}
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
{{- else }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -1,37 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-server
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-server
template:
metadata:
labels:
app: checkmate-server
spec:
{{- with .Values.server.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ .Values.server.image }}
ports:
- containerPort: {{ .Values.server.port }}
envFrom:
- secretRef:
name: checkmate-secrets
{{- with .Values.server.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
@@ -1,9 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-server
spec:
selector:
app: checkmate-server
ports:
- port: {{ .Values.server.port }}
@@ -0,0 +1,66 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-worker
spec:
{{- if not .Values.worker.autoscaling.enabled }}
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
{{- end }}
selector:
matchLabels:
app: checkmate-worker
template:
metadata:
labels:
app: checkmate-worker
spec:
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
{{- with .Values.worker.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.worker.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}
envFrom:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
value: {{ .Values.worker.healthPort | quote }}
livenessProbe:
httpGet:
path: /livez
port: health
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
{{- with .Values.worker.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
{{- end }}
@@ -0,0 +1,30 @@
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: checkmate-worker
spec:
scaleTargetRef:
name: checkmate-worker
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
triggers:
- type: mongodb
metadata:
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
query: >-
{ "type": "check", "isActive": true,
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
"$or": [ { "lockedUntil": null },
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
{{- end }}
+64 -26
View File
@@ -1,5 +1,7 @@
client:
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
image: ghcr.io/bluewave-labs/checkmate-client
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 80
protocol: http
ingress:
@@ -17,10 +19,24 @@ client:
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
server:
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
api:
image: ghcr.io/bluewave-labs/checkmate-backend
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 52345
protocol: http
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness:
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
ingress:
enabled: true
host: change_me
@@ -31,40 +47,62 @@ server:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
# Note: when enabling TLS, also set server.protocol: https and add
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
# Note: when enabling TLS, also set api.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
redis:
enabled: false
image: redis:7.2
port: 6379
mongodb:
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017
worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# API is all-in-one and processes jobs itself.
enabled: false
image: "" # defaults to api.image when empty, same as API image
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
resources: {}
affinity: {}
tolerations: {}
probes:
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness: # /readyz — flips to 503 on drain
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
autoscaling:
enabled: false # requires the KEDA operator installed in-cluster
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
maxReplicaCount: 10
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
cooldownPeriod: 300 # seconds before scaling an idle target down
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
mongo:
dbName: uptime_db # must match DB_CONNECTION_STRING's database
collection: jobs
secrets:
JWT_SECRET: change_me
# REFRESH_TOKEN_SECRET: change_me
# SYSTEM_EMAIL_ADDRESS: test@example.com
# SYSTEM_EMAIL_PASSWORD: change_me
# SYSTEM_EMAIL_HOST: smtp.example.com
# SYSTEM_EMAIL_PORT: "587"
# PAGESPEED_API_KEY: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
CLIENT_HOST: change_me
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# DB_TYPE: MongoDB
# TOKEN_TTL: 99d
# REFRESH_TOKEN_TTL: 99d
NODE_ENV: production
LOG_LEVEL: info
TOKEN_TTL: 99d
persistence:
mongo:
size: 5Gi
storageClass: ""
redis:
size: 1Gi
storageClass: ""
+22 -2
View File
@@ -1,13 +1,14 @@
{
"name": "client",
"version": "3.8.1",
"version": "3.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.8.1",
"version": "3.9.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
@@ -303,6 +304,25 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"license": "MIT",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.8.1",
"version": "3.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,9 +14,10 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
+1 -1
View File
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
+5 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -40,11 +40,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
};
break;
case "ping":
@@ -133,11 +135,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
};
}
+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",
+136 -14
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -41,8 +42,11 @@ import {
type Monitor,
type MonitorType,
type GamesMap,
type HttpMethod,
supportsGeoCheck,
DefaultPageSpeedStrategy,
DefaultHttpMethod,
HttpMethods,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag";
@@ -258,7 +262,7 @@ const CreateMonitorPage = () => {
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, watch, handleSubmit, clearErrors, trigger, reset } = form;
const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form;
useEffect(() => {
reset(defaults);
@@ -270,6 +274,7 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -1091,27 +1096,144 @@ const CreateMonitorPage = () => {
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="useAdvancedMatching"
name="method"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Select
{...field}
value={field.value ?? DefaultHttpMethod}
onChange={(e) => {
const value = e.target.value as HttpMethod;
field.onChange(value);
// HEAD has no response body, reset advanced matching fields if selected
if (value === "HEAD") {
setValue("useAdvancedMatching", false);
setValue("matchMethod", "");
setValue("expectedValue", "");
setValue("jsonPath", "");
}
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.method.label"
)}
>
{HttpMethods.map((method) => (
<MenuItem
key={method}
value={method}
>
{method}
</MenuItem>
))}
</Select>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
`pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}`
)}
</Typography>
</Stack>
)}
/>
{watchedUseAdvancedMatching && (
<Controller
name="customUpCodes"
control={control}
render={({ field }) => {
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
field.value?.includes(option.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={ALL_HTTP_STATUS_CODES}
value={selectedOptions}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(
_: unknown,
newValue: typeof ALL_HTTP_STATUS_CODES
) => {
field.onChange(newValue?.map((v) => v.id) || []);
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.customUpCodes.label"
)}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.customUpCodes.description"
)}
</Typography>
{selectedOptions.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.MD)}
width="100%"
>
{selectedOptions.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
<Typography flexGrow={1}>{option.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value?.filter((id) => id !== option.id)
);
}}
aria-label={t(
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
)}
>
<Trash2 size={16} />
</IconButton>
{index < selectedOptions.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
{watchedMethod !== "HEAD" && (
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
)}
{watchedUseAdvancedMatching && watchedMethod !== "HEAD" && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="matchMethod"
@@ -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}
+18 -41
View File
@@ -42,12 +42,15 @@ export const TableJobs = ({
maxWidth: 220,
};
const isRunning = (row: QueueJobWithId) =>
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
if (isRunning(row)) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
@@ -72,18 +75,6 @@ export const TableJobs = ({
</Typography>
),
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
@@ -92,50 +83,36 @@ export const TableJobs = ({
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
id: "lastFinishedAt",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-";
return (
<Typography
title={v}
title={lastFinishedAt}
sx={cellSx}
>
{v}
{lastFinishedAt}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
id: "nextScheduledAt",
content: t("pages.logs.table.headers.nextScheduledAt"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
return (
<Typography
title={v}
title={nextScheduledAt}
sx={cellSx}
>
{v}
{nextScheduledAt}
</Typography>
);
},
@@ -169,7 +146,7 @@ export const TableJobs = ({
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
...(isRunning(row) && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
@@ -151,12 +151,18 @@ 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));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("customCSS", data.customCSS ?? "");
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);
@@ -284,6 +290,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>
}
/>
@@ -504,6 +531,33 @@ const CreateStatusPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
@@ -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"
);
+38 -14
View File
@@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -14,6 +15,12 @@ import {
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -33,6 +40,7 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
@@ -58,6 +66,11 @@ const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
minimal: {
createStyles: minimalStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
};
const StatusPageView = () => {
@@ -67,9 +80,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,
@@ -111,17 +129,9 @@ const StatusPageView = () => {
);
}
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
return (
<StatusPageThemeProvider
theme={statusPage.theme}
@@ -129,12 +139,17 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -153,7 +168,16 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
@@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
<Box
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
</Box>
);
};
@@ -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,
},
@@ -0,0 +1,66 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import type { OverallTone } from "../shared/overallStatus";
import { refinedStyles, type RefinedStyles } from "../refined/styles";
type FlatSx = Exclude<SxProps<Theme>, readonly unknown[] | ((theme: Theme) => unknown)>;
const extend = (base: SxProps<Theme>, overrides: SxProps<Theme>): SxProps<Theme> => [
base as FlatSx,
overrides as FlatSx,
];
export const minimalStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): RefinedStyles => {
const base = refinedStyles(tokens, isDark);
return {
...base,
page: extend(base.page, {
maxWidth: 820,
p: { xs: "32px 16px 64px", md: "48px 20px 72px" },
}),
top: extend(base.top, {
mb: "44px",
pb: "16px",
borderBottom: `1px solid ${tokens.border}`,
}),
logoMono: extend(base.logoMono, {
borderRadius: "50%",
background: tokens.text,
}),
hero: extend(base.hero, {
alignItems: "center",
background: "transparent",
border: 0,
boxShadow: "none",
p: 0,
mb: "40px",
}),
heroRow: extend(base.heroRow, { justifyContent: "center" }),
heroTitle: extend(base.heroTitle, {
flex: "initial",
fontSize: { xs: 22, md: 28 },
fontWeight: 650,
textAlign: "center",
}),
heroIcon: (_tone: OverallTone) => ({ display: "none" }),
heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }),
chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }),
card: extend(base.card, {
boxShadow: "none",
transition: "none",
"&:hover": { transform: "none", boxShadow: "none" },
}),
cardRow: extend(base.cardRow, { p: "14px 16px" }),
badge: (tone) =>
extend(base.badge(tone), {
background: "transparent",
p: 0,
}),
gauge: extend(base.gauge, { borderRadius: tokens.radius }),
footer: extend(base.footer, { mt: "48px", fontSize: 11 }),
};
};
@@ -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,10 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return (
<Box sx={styles.page}>
<Box
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
sx={styles.top}
>
<HeaderSlot
@@ -112,7 +115,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
monitorCount={monitors.length}
styles={styles}
/>
</Box>
</Stack>
<HeroSlot
statusPage={statusPage}
@@ -161,6 +164,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 +173,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 && (
@@ -191,9 +191,47 @@ const editorial: ThemeVariants = {
},
};
const minimal: ThemeVariants = {
light: {
bg: "#ffffff",
surface: "#ffffff",
border: "#dfe3e8",
text: "#17191c",
textMuted: "#667085",
up: "#12a174",
upStrong: "#087a57",
upSoft: "#e7f7f1",
degraded: "#b54708",
degradedSoft: "#fff1dc",
down: "#c4323f",
downSoft: "#fde8ea",
warn: "#c27803",
warnSoft: "#fff3d6",
radius: "6px",
},
dark: {
bg: "#0d0f12",
surface: "#111419",
border: "#2b3038",
text: "#f2f4f7",
textMuted: "#98a2b3",
up: "#32d6a0",
upStrong: "#1eb783",
upSoft: "rgba(50,214,160,0.14)",
degraded: "#f0a44b",
degradedSoft: "rgba(240,164,75,0.16)",
down: "#ff6675",
downSoft: "rgba(255,102,117,0.16)",
warn: "#f2b84b",
warnSoft: "rgba(242,184,75,0.16)",
radius: "6px",
},
};
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
refined,
modern,
bold,
editorial,
minimal,
};
@@ -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"),
@@ -304,7 +324,9 @@ export const MonitorTable = ({
</Stack>
),
render: (row) => {
return row.type;
return row.type === "http" && row.method
? `${row.type} (${row.method})`
: row.type;
},
},
{
+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;
+6
View File
@@ -48,6 +48,10 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export interface Monitor {
id: string;
userId: string;
@@ -64,6 +68,7 @@ export interface Monitor {
jsonPath?: string;
expectedValue?: string;
matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string;
port?: number;
isActive: boolean;
@@ -71,6 +76,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes?: number[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
+1 -8
View File
@@ -30,23 +30,16 @@ export type QueueMetrics = {
export type QueueJobSummary = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedBy: string | null;
lockedUntil: number | null;
lockedAt: number | null;
nextScheduledAt: number;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface QueueData {
+8 -1
View File
@@ -21,7 +21,13 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type;
};
export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
export const STATUS_PAGE_THEMES = [
"refined",
"modern",
"bold",
"editorial",
"minimal",
] as const;
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
@@ -47,6 +53,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;
+16
View File
@@ -0,0 +1,16 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
+93
View File
@@ -74,3 +74,96 @@ export const getStatusCodeValueType = (code: number): ValueType => {
if (code < 400) return "neutral";
return "negative";
};
export interface HttpStatusCodeOption {
id: number;
name: string;
}
/**
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
*/
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
{ id: 100, name: "100 - Continue" },
{ id: 101, name: "101 - Switching Protocols" },
{ id: 102, name: "102 - Processing" },
{ id: 103, name: "103 - Early Hints" },
{ id: 200, name: "200 - OK" },
{ id: 201, name: "201 - Created" },
{ id: 202, name: "202 - Accepted" },
{ id: 203, name: "203 - Non-Authoritative Information" },
{ id: 204, name: "204 - No Content" },
{ id: 205, name: "205 - Reset Content" },
{ id: 206, name: "206 - Partial Content" },
{ id: 207, name: "207 - Multi-Status" },
{ id: 208, name: "208 - Already Reported" },
{ id: 226, name: "226 - IM Used" },
{ id: 300, name: "300 - Multiple Choices" },
{ id: 301, name: "301 - Moved Permanently" },
{ id: 302, name: "302 - Found" },
{ id: 303, name: "303 - See Other" },
{ id: 304, name: "304 - Not Modified" },
{ id: 305, name: "305 - Use Proxy" },
{ id: 307, name: "307 - Temporary Redirect" },
{ id: 308, name: "308 - Permanent Redirect" },
{ id: 400, name: "400 - Bad Request" },
{ id: 401, name: "401 - Unauthorized" },
{ id: 402, name: "402 - Payment Required" },
{ id: 403, name: "403 - Forbidden" },
{ id: 404, name: "404 - Not Found" },
{ id: 405, name: "405 - Method Not Allowed" },
{ id: 406, name: "406 - Not Acceptable" },
{ id: 407, name: "407 - Proxy Authentication Required" },
{ id: 408, name: "408 - Request Timeout" },
{ id: 409, name: "409 - Conflict" },
{ id: 410, name: "410 - Gone" },
{ id: 411, name: "411 - Length Required" },
{ id: 412, name: "412 - Precondition Failed" },
{ id: 413, name: "413 - Payload Too Large" },
{ id: 414, name: "414 - URI Too Long" },
{ id: 415, name: "415 - Unsupported Media Type" },
{ id: 416, name: "416 - Range Not Satisfiable" },
{ id: 417, name: "417 - Expectation Failed" },
{ id: 418, name: "418 - I'm a Teapot" },
{ id: 419, name: "419 - Page Expired (Laravel)" },
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
{ id: 421, name: "421 - Misdirected Request" },
{ id: 422, name: "422 - Unprocessable Entity" },
{ id: 423, name: "423 - Locked" },
{ id: 424, name: "424 - Failed Dependency" },
{ id: 425, name: "425 - Too Early" },
{ id: 426, name: "426 - Upgrade Required" },
{ id: 428, name: "428 - Precondition Required" },
{ id: 429, name: "429 - Too Many Requests" },
{ id: 431, name: "431 - Request Header Fields Too Large" },
{ id: 440, name: "440 - Login Time-out (IIS)" },
{ id: 449, name: "449 - Retry With (IIS)" },
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
{ id: 500, name: "500 - Internal Server Error" },
{ id: 501, name: "501 - Not Implemented" },
{ id: 502, name: "502 - Bad Gateway" },
{ id: 503, name: "503 - Service Unavailable" },
{ id: 504, name: "504 - Gateway Timeout" },
{ id: 505, name: "505 - HTTP Version Not Supported" },
{ id: 506, name: "506 - Variant Also Negotiates" },
{ id: 507, name: "507 - Insufficient Storage" },
{ id: 508, name: "508 - Loop Detected" },
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
{ id: 510, name: "510 - Not Extended" },
{ id: 511, name: "511 - Network Authentication Required" },
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
{ id: 529, name: "529 - Site is overloaded" },
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
];
+99
View File
@@ -0,0 +1,99 @@
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}`;
};
// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
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;
};
+17 -1
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { GeoContinents } from "@/Types/GeoCheck";
import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor";
import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -53,9 +59,15 @@ const geoCheckFields = {
};
// HTTP monitor schema
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
message: "Must be a valid HTTP status code",
});
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
matchMethod: z
@@ -64,6 +76,10 @@ const httpSchema = baseSchema.extend({
.register(monitorStepRegistry, { step: 2 }),
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
customUpCodes: z
.array(httpStatusCode)
.optional()
.register(monitorStepRegistry, { step: 2 }),
...geoCheckFields,
});
+32 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -9,6 +10,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 +40,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"]))
@@ -35,7 +59,14 @@ export const statusPageSchema = z.object({
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }),
customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }),
customCSS: z
.string()
.max(100000, "Custom CSS must be at most 100000 characters")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "معرّف المراقب",
"runCount": "عدد التشغيلات",
"failCount": "عدد حالات الفشل",
"lastRunAt": "آخر تشغيل في",
"lockedAt": "مقفل في",
"lastFinishedAt": "آخر انتهاء في",
"lastRunTook": "مدة آخر تشغيل",
"lastFailedAt": "آخر فشل في",
"failReason": "سبب الفشل",
"state": "الحالة"
-3
View File
@@ -945,10 +945,7 @@
"monitorId": "ID del monitor",
"runCount": "Recompte d'execucions",
"failCount": "Recompte",
"lastRunAt": "Darrera execució",
"lockedAt": "Bloquejat el",
"lastFinishedAt": "Darrera finalització",
"lastRunTook": "Durada de la darrera execució",
"lastFailedAt": "Darrera fallada",
"failReason": "Motiu de la fallada",
"state": "Estat"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID monitoru",
"runCount": "Počet spuštění",
"failCount": "Počet selhání",
"lastRunAt": "Poslední spuštění",
"lockedAt": "Uzamčeno v",
"lastFinishedAt": "Naposledy dokončeno",
"lastRunTook": "Poslední běh trval",
"lastFailedAt": "Poslední selhání",
"failReason": "Důvod selhání",
"state": "Stav"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitor-ID",
"runCount": "Ausführungen",
"failCount": "Fehlerzahl",
"lastRunAt": "Letzter Lauf am",
"lockedAt": "Gesperrt am",
"lastFinishedAt": "Zuletzt abgeschlossen am",
"lastRunTook": "Letzter Lauf dauerte",
"lastFailedAt": "Letzter Fehler am",
"failReason": "Fehlergrund",
"state": "Status"
+42 -4
View File
@@ -490,6 +490,13 @@
"advanced": {
"description": "Optional settings for advanced use cases",
"option": {
"method": {
"label": "Request method",
"description": {
"GET": "GET fetches the full response including the body. Advanced matching fully supported",
"HEAD": "HEAD fetches only response headers for reducing load on the target. Supports status code matching but not advanced matching as there is no body."
}
},
"advancedMatching": {
"label": "Use advanced matching"
},
@@ -505,6 +512,11 @@
"equal": "Equal",
"include": "Include",
"regex": "Regex"
},
"customUpCodes": {
"label": "Accepted status codes",
"description": "Add any non-2xx codes that should also be considered successful.",
"removeAriaLabel": "Remove status code"
}
},
"title": "Advanced settings"
@@ -975,6 +987,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",
@@ -1015,11 +1032,9 @@
"monitorId": "Monitor ID",
"runCount": "Run count",
"failCount": "Count",
"lastRunAt": "Last run at",
"lockedAt": "Locked at",
"nextScheduledAt": "Next run at",
"lockedBy": "Locked by",
"lastFinishedAt": "Last finished at",
"lastRunTook": "Last run took",
"lastFailedAt": "Last failed",
"failReason": "Fail reason"
}
@@ -1509,6 +1524,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."
}
}
},
@@ -1565,6 +1585,10 @@
"editorial": {
"name": "Editorial",
"description": "Serif headings and hairline dividers. Distinctive and calm."
},
"minimal": {
"name": "Minimal",
"description": "Flat, focused, and compact with only the essentials."
}
}
},
@@ -1574,6 +1598,16 @@
"light": "Light only",
"dark": "Dark only"
},
"customCSS": {
"title": "Custom CSS",
"description": "Add custom CSS to fine-tune the look of your public status page.",
"option": {
"customCSS": {
"label": "Custom CSS",
"placeholder": "footer { display: none; }"
}
}
},
"features": {
"title": "Features",
"description": "Configure what information is displayed on your status page.",
@@ -1596,6 +1630,9 @@
"header": {
"title": "Status pages",
"description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders."
},
"preview": {
"title": "Status page preview"
}
},
"uptime": {
@@ -1607,7 +1644,8 @@
},
"table": {
"headers": {
"responseTime": "Response time"
"responseTime": "Response time",
"uptime": "Uptime %"
}
},
"fallback": {
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID de monitor",
"runCount": "Conteo de ejecuciones",
"failCount": "Conteo de fallas",
"lastRunAt": "Última ejecución",
"lockedAt": "Bloqueado a las",
"lastFinishedAt": "Última finalización",
"lastRunTook": "Última ejecución tomó",
"lastFailedAt": "Última falla",
"failReason": "Razón de falla",
"state": "Estado"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitorin tunnus",
"runCount": "Suorituskerrat",
"failCount": "Epäonnistumiskerrat",
"lastRunAt": "Viimeisin suoritus",
"lockedAt": "Lukittu",
"lastFinishedAt": "Viimeisin valmistuminen",
"lastRunTook": "Viimeisin suoritus kesti",
"lastFailedAt": "Viimeisin epäonnistuminen",
"failReason": "Epäonnistumisen syy",
"state": "Tila"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID du moniteur",
"runCount": "Nombre d'exécutions",
"failCount": "Nombre d'échecs",
"lastRunAt": "Dernière exécution",
"lockedAt": "Verrouillé le",
"lastFinishedAt": "Dernière fin",
"lastRunTook": "Durée dernière exécution",
"lastFailedAt": "Dernier échec",
"failReason": "Raison de l'échec",
"state": "État"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "モニターID",
"runCount": "実行回数",
"failCount": "失敗回数",
"lastRunAt": "最終実行日時",
"lockedAt": "ロック日時",
"lastFinishedAt": "最終完了日時",
"lastRunTook": "最終実行時間",
"lastFailedAt": "最終失敗日時",
"failReason": "失敗理由",
"state": "状態"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID do monitor",
"runCount": "Contagem de execuções",
"failCount": "Contagem de falhas",
"lastRunAt": "Última execução em",
"lockedAt": "Bloqueado em",
"lastFinishedAt": "Última conclusão em",
"lastRunTook": "Última execução durou",
"lastFailedAt": "Última falha em",
"failReason": "Motivo da falha",
"state": "Estado"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID монитора",
"runCount": "Кол-во запусков",
"failCount": "Кол-во ошибок",
"lastRunAt": "Последний запуск",
"lockedAt": "Заблокировано в",
"lastFinishedAt": "Последнее завершение",
"lastRunTook": "Последний запуск занял",
"lastFailedAt": "Последняя ошибка",
"failReason": "Причина ошибки",
"state": "Состояние"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "รหัสมอนิเตอร์",
"runCount": "จำนวนรัน",
"failCount": "จำนวนล้มเหลว",
"lastRunAt": "รันล่าสุดเมื่อ",
"lockedAt": "ล็อกเมื่อ",
"lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ",
"lastRunTook": "รันล่าสุดใช้เวลา",
"lastFailedAt": "ล้มเหลวล่าสุดเมื่อ",
"failReason": "สาเหตุที่ล้มเหลว",
"state": "สถานะ"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "Monitör ID",
"runCount": "Çalışma sayısı",
"failCount": "Başarısızlık sayısı",
"lastRunAt": "Son çalışma zamanı",
"lockedAt": "Kilitlenme zamanı",
"lastFinishedAt": "Son tamamlanma zamanı",
"lastRunTook": "Son çalışma süresi",
"lastFailedAt": "Son başarısızlık zamanı",
"failReason": "Başarısızlık nedeni",
"state": "Durum"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID монітора",
"runCount": "Кількість запусків",
"failCount": "Кількість помилок",
"lastRunAt": "Останній запуск",
"lockedAt": "Заблоковано о",
"lastFinishedAt": "Останнє завершення",
"lastRunTook": "Останній запуск тривав",
"lastFailedAt": "Остання помилка",
"failReason": "Причина помилки",
"state": "Стан"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "ID trình giám sát",
"runCount": "Số lần chạy",
"failCount": "Số lần lỗi",
"lastRunAt": "Chạy lần cuối lúc",
"lockedAt": "Khóa lúc",
"lastFinishedAt": "Hoàn thành lần cuối lúc",
"lastRunTook": "Lần chạy cuối mất",
"lastFailedAt": "Lỗi lần cuối lúc",
"failReason": "Lý do lỗi",
"state": "Trạng thái"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "监控 ID",
"runCount": "运行次数",
"failCount": "失败次数",
"lastRunAt": "上次运行于",
"lockedAt": "锁定于",
"lastFinishedAt": "上次完成于",
"lastRunTook": "上次运行耗时",
"lastFailedAt": "上次失败于",
"failReason": "失败原因",
"state": "状态"
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "監控 ID",
"runCount": "執行次數",
"failCount": "失敗次數",
"lastRunAt": "最後執行於",
"lockedAt": "鎖定於",
"lastFinishedAt": "最後完成於",
"lastRunTook": "最後執行耗時",
"lastFailedAt": "最後失敗於",
"failReason": "失敗原因",
"state": "狀態"
-4
View File
@@ -2,14 +2,10 @@
!quickstart.sh
!build_images.sh
dev/mongo/data/*
dev/redis/data/*
dist/mongo/data/*
dist/redis/data/*
prod/mongo/data/*
prod/redis/data/*
dist/docker-compose-test.yaml
/dist-mono/mongo/data/*
/dist-mono/redis/data/*
/dist-arm/mongo/data/*
*.env
+3 -6
View File
@@ -12,14 +12,11 @@ set dockerfiles[0]=.\docker\coolify\client.Dockerfile
set services[1]=bluewaveuptime/uptime_database_mongo
set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile
set services[2]=bluewaveuptime/uptime_redis
set dockerfiles[2]=.\docker\coolify\redis.Dockerfile
set services[3]=bluewaveuptime/uptime_server
set dockerfiles[3]=.\docker\coolify\server.Dockerfile
set services[2]=bluewaveuptime/uptime_server
set dockerfiles[2]=.\docker\coolify\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2,3) do (
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
-1
View File
@@ -7,7 +7,6 @@ cd ../../
declare -A services=(
["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile"
["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile"
["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile"
["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile"
)
-2
View File
@@ -1,2 +0,0 @@
FROM redis
EXPOSE 6379
@@ -19,7 +19,6 @@ services:
# Mount the certs directory (read-only for security)
- ./certs:/certs:ro
depends_on:
- redis
- mongodb
# Optional: Add healthcheck to ensure the server starts properly
healthcheck:
+26
View File
@@ -20,9 +20,35 @@ services:
- server.env
depends_on:
- mongodb
worker:
image: uptime_server:latest
restart: always
ports:
- "52346:52346"
env_file:
- server.env
environment:
QUEUE_MODE: "worker"
HEALTH_PORT: "52346"
stop_grace_period: 30s
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:52346/livez',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))",
]
interval: 15s
timeout: 3s
retries: 3
depends_on:
- mongodb
mongodb:
image: uptime_mongo:latest
restart: always
ports:
- "27017:27017"
command: ["mongod", "--quiet", "--bind_ip_all"]
volumes:
- ./mongo/data:/data/db
-1
View File
@@ -9,7 +9,6 @@ services:
- UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1
- UPTIME_APP_CLIENT_HOST=http://localhost
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- REDIS_URL=redis://redis:6379
- CLIENT_HOST=http://localhost
- JWT_SECRET=my_secret
depends_on:
+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"
}
+176 -2
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": [
{
@@ -386,6 +386,10 @@
"type": "string",
"enum": ["equal", "include", "regex"]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"notifications": {
"type": "array",
"items": {
@@ -398,6 +402,12 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
@@ -460,6 +470,9 @@
},
"updatedAt": {
"type": "string"
},
"lastEvaluatedAt": {
"type": "number"
}
},
"required": [
@@ -474,6 +487,7 @@
"statusWindowThreshold",
"ignoreTlsErrors",
"useAdvancedMatching",
"method",
"notifications",
"tags",
"cpuAlertThreshold",
@@ -487,7 +501,8 @@
"teamId",
"userId",
"createdAt",
"updatedAt"
"updatedAt",
"lastEvaluatedAt"
],
"additionalProperties": {
"nullable": true
@@ -7016,6 +7031,13 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
},
"default": []
},
"secret": {
"type": "string"
},
@@ -7053,6 +7075,10 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"gameId": {
"anyOf": [
{
@@ -7653,6 +7679,11 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"],
"default": "GET"
},
"url": {
"type": "string",
"minLength": 1
@@ -7685,6 +7716,13 @@
},
"default": []
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
},
"default": []
},
"secret": {
"type": "string"
},
@@ -8209,6 +8247,12 @@
"type": "string"
}
},
"customUpCodes": {
"type": "array",
"items": {
"type": "number"
}
},
"secret": {
"type": "string"
},
@@ -8252,6 +8296,10 @@
}
]
},
"method": {
"type": "string",
"enum": ["GET", "HEAD"]
},
"port": {
"type": "number",
"minimum": 1,
@@ -10422,6 +10470,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"
},
@@ -10461,6 +10520,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
@@ -10625,6 +10688,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"
},
@@ -10664,6 +10738,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
@@ -10894,6 +10972,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}",
+1216 -988
View File
File diff suppressed because it is too large. Load diff
+3 -5
View File
@@ -22,7 +22,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "prettier --write --ignore-unknown"
},
"keywords": [],
"author": "",
@@ -31,9 +31,9 @@
"node": ">=20"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@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 +48,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 +60,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 +93,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";
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff. Show more