Merge branch 'develop' of github.com:bluewave-labs/Checkmate into develop

This commit is contained in:
Alex committed 2026-07-02 10:30:05 -07:00
commit 5a72e1e123
54 files changed
+992 -206

No files matched your search

+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: ""
+20
View File
@@ -8,6 +8,7 @@
"name": "client",
"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",
+1
View File
@@ -17,6 +17,7 @@
"**/*": "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",
},
@@ -162,6 +162,7 @@ const CreateStatusPage = () => {
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);
@@ -530,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")}
+25 -11
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";
@@ -16,6 +17,7 @@ import {
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
@@ -38,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: {
@@ -63,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 = () => {
@@ -121,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}
@@ -139,12 +139,17 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}
const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -163,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>
);
};
@@ -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 }),
};
};
@@ -102,6 +102,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return (
<Box sx={styles.page}>
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
@@ -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,
};
+7 -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";
+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)
);
};
+10
View File
@@ -70,6 +70,16 @@ export const getStatusPagePublicUrl = (
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;
+9 -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
@@ -58,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()
+17
View File
@@ -1585,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."
}
}
},
@@ -1594,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.",
@@ -1616,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": {
-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:
-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:
+8
View File
@@ -10520,6 +10520,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
@@ -10734,6 +10738,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
+1
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"axios": "^1.15.2",
+1
View File
@@ -31,6 +31,7 @@
"node": ">=20"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"axios": "^1.15.2",
+9 -7
View File
@@ -1,12 +1,14 @@
import rateLimit from "express-rate-limit";
export const generalApiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 600,
standardHeaders: true,
legacyHeaders: false,
ipv6Subnet: 64,
});
export const generalApiLimiter = (devMode: boolean) =>
rateLimit({
windowMs: 60 * 1000,
limit: 600,
standardHeaders: true,
legacyHeaders: false,
ipv6Subnet: 64,
skip: () => devMode,
});
export const authApiLimiter = rateLimit({
windowMs: 60 * 1000,
@@ -0,0 +1,27 @@
import type { NextFunction, Request, Response } from "express";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
// Browsers enforce the intersection of all CSP headers, so this only tightens
// the status page on top of the global helmet policy, blocking external images,
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
const STATUS_PAGE_CSP = [
"img-src 'self' data:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");
// A status page document is served on the public path or on a custom domain
// (any host other than the app's own). Mirrors the client isCustomDomainHost.
export const createStatusPageDocumentCsp = (clientHost: string) => {
const appHostname = normalizeStatusPageDomain(clientHost);
return (req: Request, res: Response, next: NextFunction) => {
const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname;
if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) {
res.append("Content-Security-Policy", STATUS_PAGE_CSP);
}
return next();
};
};
+4 -3
View File
@@ -1,5 +1,6 @@
import { IStatusPageController } from "@/api/controllers/statusPageController.js";
import { RequestHandler, Router } from "express";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { imageUpload } from "@/api/middleware/upload.js";
class StatusPageRoutes {
@@ -15,12 +16,12 @@ class StatusPageRoutes {
initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId);
this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage);
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage);
this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain);
this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl);
this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage);
this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage);
}
getRouter() {
@@ -2,6 +2,7 @@ import { z } from "zod";
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { cssReferencesExternalResource } from "@/utils/customCss.js";
//****************************************
// Status Page Validations
@@ -48,6 +49,13 @@ export const createStatusPageBodyValidation = z
showUptimePercentage: booleanCoercion,
showAdminLoginLink: booleanCoercion.optional(),
showInfrastructure: booleanCoercion.optional(),
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(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
theme: z.enum(StatusPageThemes).optional(),
themeMode: z.enum(StatusPageThemeModes).optional(),
+5 -1
View File
@@ -13,6 +13,7 @@ import { InitializedControllers } from "@/config/controllers.js";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js";
import { ApiServices } from "@/config/services.api.js";
export const createApp = ({
@@ -38,7 +39,8 @@ export const createApp = ({
};
const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository);
app.use(generalApiLimiter);
const devMode = envSettings.nodeEnv === "development";
app.use(generalApiLimiter(devMode));
app.use((req, res, next) => {
const corsOptions = isPublicStatusPageApiPath(req.method, req.path)
@@ -48,6 +50,8 @@ export const createApp = ({
return cors(corsOptions)(req, res, next);
});
app.use(createStatusPageDocumentCsp(allowedOrigin));
app.use(express.static(frontendPath));
app.use(express.json());
@@ -1,7 +1,7 @@
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const;
export const StatusPageThemes = ["refined", "modern", "bold", "editorial", "minimal"] as const;
export type StatusPageTheme = (typeof StatusPageThemes)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
+14
View File
@@ -0,0 +1,14 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
// Tokenizing resolves comments, escapes, and line continuations so obfuscated
// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set().
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));
};
@@ -0,0 +1,56 @@
import { describe, expect, it, jest } from "@jest/globals";
import type { NextFunction, Request, Response } from "express";
import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts";
const makeRes = (): Response => {
const res = {} as Response;
res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"];
return res;
};
const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001");
const run = (req: Partial<Request>) => {
const res = makeRes();
const next = jest.fn() as unknown as NextFunction;
cspMiddleware(req as Request, res, next);
return { res, next };
};
const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1];
describe("statusPageDocumentCsp", () => {
it("appends a tightened CSP on the public status page path", () => {
const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" });
expect(res.append).toHaveBeenCalledTimes(1);
const value = appendedValue(res);
expect(value).toContain("img-src 'self' data:");
expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com");
expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com");
expect(next).toHaveBeenCalledTimes(1);
});
it("does not restrict default-src or connect-src so the page can still reach the API", () => {
const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" });
const value = appendedValue(res);
expect(value).not.toContain("default-src");
expect(value).not.toContain("connect-src");
});
it("appends the CSP on a custom domain document at the host root", () => {
const { res, next } = run({ path: "/", hostname: "status.example.com" });
expect(res.append).toHaveBeenCalledTimes(1);
expect(appendedValue(res)).toContain("img-src 'self' data:");
expect(next).toHaveBeenCalledTimes(1);
});
it("leaves the app's own non-public routes untouched", () => {
const { res, next } = run({ path: "/status/create", hostname: "localhost" });
expect(res.append).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "@jest/globals";
import { cssReferencesExternalResource } from "../../../src/utils/customCss.ts";
describe("cssReferencesExternalResource", () => {
it("returns false for empty or non-string input", () => {
expect(cssReferencesExternalResource("")).toBe(false);
expect(cssReferencesExternalResource(null)).toBe(false);
expect(cssReferencesExternalResource(undefined)).toBe(false);
});
it("allows benign CSS", () => {
expect(cssReferencesExternalResource("li{outline:3px dashed red}footer{display:none}")).toBe(false);
});
it("allows data: and relative urls", () => {
expect(cssReferencesExternalResource("li{background:url(data:image/png;base64,AAAA)}")).toBe(false);
expect(cssReferencesExternalResource("li{background:url(/assets/logo.png)}")).toBe(false);
expect(cssReferencesExternalResource("li{background:url(logo.png)}")).toBe(false);
});
it("allows data: SVG that embeds the SVG namespace URL", () => {
const css = `li{background:url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>")}`;
expect(cssReferencesExternalResource(css)).toBe(false);
});
it("flags @import", () => {
expect(cssReferencesExternalResource('@import url("https://evil.example/x.css");')).toBe(true);
expect(cssReferencesExternalResource('@import "https://evil.example/x.css";')).toBe(true);
});
it("flags external url()", () => {
expect(cssReferencesExternalResource('body{background:url("https://evil.example/x")}')).toBe(true);
expect(cssReferencesExternalResource("body{background:url(http://evil.example/x)}")).toBe(true);
});
it("flags protocol-relative url()", () => {
expect(cssReferencesExternalResource("body{background:url(//evil.example/x)}")).toBe(true);
});
it("flags external string-form image functions", () => {
expect(cssReferencesExternalResource('body{background:image-set("https://evil.example/x" 1x)}')).toBe(true);
expect(cssReferencesExternalResource('body{background:-webkit-image-set("//evil.example/x" 1x)}')).toBe(true);
expect(cssReferencesExternalResource('body{background-image:image("https://evil.example/x")}')).toBe(true);
});
it("flags url() obfuscated with a trailing parse error", () => {
expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true);
});
it("flags references obfuscated with inline comments", () => {
expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true);
expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true);
expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true);
});
it("allows a comment that makes url() a bad token the browser never fetches", () => {
expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false);
});
it("flags url() obfuscated with CSS escapes", () => {
expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true);
expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true);
});
it("flags a scheme split by a line continuation", () => {
expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true);
expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true);
});
it("flags a scheme split by a hex escape", () => {
expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true);
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from "@jest/globals";
import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts";
const baseCreateBody = (overrides: Record<string, unknown> = {}) => ({
type: "uptime",
companyName: "Test Co",
url: "my-status-page",
monitors: ["0123456789abcdef01234567"],
isPublished: true,
showUptimePercentage: true,
...overrides,
});
describe("createStatusPageBodyValidation", () => {
it("accepts a valid body without customCSS", () => {
const result = createStatusPageBodyValidation.safeParse(baseCreateBody());
expect(result.success).toBe(true);
});
it("accepts and preserves customCSS", () => {
const customCSS = ".status-page > footer { display: none; }";
const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customCSS).toBe(customCSS);
}
});
it("accepts an empty customCSS so it can be cleared", () => {
const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "" }));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customCSS).toBe("");
}
});
it("rejects customCSS over the maximum length", () => {
const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "a".repeat(100001) }));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([expect.objectContaining({ message: "Custom CSS must be at most 100000 characters", path: ["customCSS"] })])
);
}
});
it("rejects a non-string customCSS", () => {
const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 }));
expect(result.success).toBe(false);
});
it("rejects customCSS that references an external resource", () => {
const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 'body{background:url("https://evil.example/beacon")}' }));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: "Custom CSS cannot reference external URLs or use @import", path: ["customCSS"] }),
])
);
}
});
});