Compare commits

..
Author SHA1 Message Date
gorkem-bwl a672f7604d fix(monitors): bound certificate probe and gate client on scheme
Addresses review feedback on #3848.

Timeout: the probe now dials a user-defined port, so it can reach a
service that accepts the connection but never completes the TLS
handshake, hanging the request indefinitely. ssl-checker already wires
a "timeout" listener that rejects with "Timed Out", but its defaults
set no timeout, so it never fired. Passing one activates it.

The bundled type declaration is a hand-maintained subset of
https.RequestOptions and omitted "timeout"; it is added there so the
call typechecks against an option the library already supports.

Client gating: "http" is a single monitor type covering both schemes,
so an http:// monitor passed the existing type check and requested a
certificate anyway. With the server now rejecting those, the details
page would surface a 400 on every visit. The scheme is gated client
side to match, and the stat box falls back to "N/A" as before.

Test comment removed as requested; the timeout is covered by a new
assertion in the same file.
2026-08-25 18:14:25 -04:00
gorkem-bwl 25a7ac4877 fix(monitors): reject certificate lookups for plain-HTTP monitors
Passing the monitor's own port to ssl-checker exposed an adjacent gap found
in review: a plain-HTTP monitor on a non-default port, such as
http://host:8080, now had a TLS handshake attempted against a port that
serves no TLS. That surfaced to the client as a 500 carrying a raw OpenSSL
error ("packet length too long") instead of the previous silent "N/A".

The endpoint never checked the scheme — it was already wrong for http://
monitors, just quietly so, since every such lookup went to port 443. Guard
on the protocol and fail with a 400 before opening a socket.

Adds tests for both plain-HTTP cases (default and custom port), each
asserting the checker is never invoked. Verified by removing the guard:
both fail without it.
2026-08-25 18:14:25 -04:00
gorkem-bwl f74d1d53f6 fix(monitors): check TLS certificate on the monitor's own port
fetchMonitorCertificate parsed the monitor URL but passed only the hostname
to ssl-checker, which then defaulted to 443. A monitor on a non-standard
port such as https://host:54321 therefore had the certificate of whatever
answered on 443 inspected instead of its own, surfacing as "N/A" in the
certificate expiry box when nothing was listening there.

URL.port is an empty string for default ports, so it is mapped to 443
explicitly rather than passed through Number(), which would have yielded
NaN. ssl-checker already accepts the port via SSLOptions, so no dependency
or signature change is needed.

Adds a regression suite covering the reported case (non-standard port), the
default-port fallback, an explicit :443, and the NaN guard. Verified by
reverting the fix: the three port assertions fail without it.

Fixes #3646
2026-08-25 18:14:25 -04:00
50 changed files with 1311 additions and 1031 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+4 -9
View File
@@ -32,13 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br>
@@ -92,10 +91,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
The image is configured entirely through environment variables on the server container:
@@ -212,9 +207,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+6 -2
View File
@@ -2,5 +2,9 @@ apiVersion: v2
name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.3.0
appVersion: "latest" # the all-in-one image publishes :latest on release tags; pin a version here to freeze
version: 0.2.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+3 -41
View File
@@ -19,8 +19,8 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml
Edit `values.yaml` to update:
- `app.ingress.host` with your domain name
- `app.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`
- Add: `persistence.mongo.storageClass` (leave empty for default)
@@ -95,7 +95,7 @@ chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate # repo only
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
```
@@ -244,41 +244,3 @@ kubectl get secrets | grep checkmate-tls
```
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
## Upgrading to chart 0.3.0 (all-in-one image)
Checkmate now ships as a single image that serves both the client and the API, so
the chart deploys one workload and one ingress instead of two.
**What changed**
| 0.2.x | 0.3.0 |
|---|---|
| `client.*` + `api.*` value blocks | one `app.*` block |
| `checkmate-client` + `checkmate-backend` images | `ghcr.io/bluewave-labs/checkmate` |
| `ghcr.io/bluewave-labs/checkmate-mongo` | upstream `mongo:8.0` |
| Deployment `checkmate-api` + `checkmate-client` | Deployment `checkmate` |
| Ingress `checkmate-api-ingress` (`/api/v1`) + client ingress (`/`) | Ingress `checkmate-ingress` (`/`) |
| `UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST` | not set — the client is same-origin |
`appVersion` is no longer frozen at v3.8.1; the chart tracks the published image again.
**Upgrading an existing install**
Existing `client.*`, `api.*` and `server.*` overrides are still read and merged over
the `app.*` defaults, so most values files upgrade without edits. Two cases fail
deliberately rather than deploying something wrong:
- `client.image` set — that image is no longer published, so the chart stops instead
of leaving you with an ImagePullBackOff. Remove the `client:` block.
- `client.ingress.host` and `api.ingress.host` set to *different* hosts — one origin
now serves both, so the chart cannot honour a split. Set a single `app.ingress.host`.
Migrate overrides to `app.*` when convenient; the legacy blocks are transitional.
**One ingress, one host**
If you previously served the client and API on separate hostnames, external clients
now use the single `app.ingress.host`. Update any bookmarks, uptime checks, or DNS
records that pointed at the old API hostname.
+11 -34
View File
@@ -1,41 +1,18 @@
{{- /*
Back-compat shim for the all-in-one image migration.
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).
Chart 0.2.x deployed a separate client and API, configured through `client.*` and
`api.*` (and, before that, `server.*`). Checkmate now ships one image that serves
both, so those collapse into a single `app.*` block.
Existing installs keep working: any legacy block is merged over the `app.*`
defaults, in the order client -> api -> server, so a value set in an older block
still wins. Only `image`/`tag` are deliberately NOT inherited from `client.*` —
that repo (checkmate-client) is no longer published, so honouring it would pin an
install to an image that cannot be pulled.
New installs have no legacy keys and this is a no-op deepCopy of `.Values.app`.
Consume it as:
{{- $app := include "checkmate.app" . | fromYaml -}}
*/}}
{{- define "checkmate.app" -}}
{{- $app := deepCopy .Values.app -}}
{{- with .Values.client -}}
{{- $legacy := omit (deepCopy .) "image" "tag" "port" -}}
{{- $app = mergeOverwrite $app $legacy -}}
{{- end -}}
{{- with .Values.api -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- with .Values.server -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- $app | toYaml -}}
{{- end -}}
{{- /*
Deprecated alias kept so any out-of-tree template referencing `checkmate.api`
still resolves. Prefer `checkmate.app`.
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" -}}
{{- include "checkmate.app" . -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- 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 }}
@@ -0,0 +1,39 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
annotations:
{{- range $key, $value := $api.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{/*#annotations:
#nginx.ingress.kubernetes.io/rewrite-target: /
#nginx.ingress.kubernetes.io/enable-cors: "true"
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
#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: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
tls:
- hosts:
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $api.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-api
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,71 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate
template:
metadata:
labels:
app: checkmate
spec:
{{- with $app.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $app.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: checkmate
image: {{ include "checkmate.image" (dict "image" $app.image "tag" $app.tag "root" $) }}
imagePullPolicy: {{ $app.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $app.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 app 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: {{ $app.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $app.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $app.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $app.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $app.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $app.probes.readiness.failureThreshold }}
{{- with $app.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
# The client is served from this same origin, so CLIENT_HOST defaults to
# the app's own ingress URL. `env` takes precedence over `envFrom`, so an
# explicit secrets.CLIENT_HOST is honoured by omitting this entry rather
# than emitting a value that would silently win over it.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
- name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
{{- end }}
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the app 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,35 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- if $app.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-ingress
{{- if $app.ingress.annotations }}
annotations:
{{- range $key, $value := $app.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ $app.ingress.className }}
{{- if $app.ingress.tls.enabled }}
tls:
- hosts:
- {{ $app.ingress.host }}
secretName: {{ default (printf "%s-tls" .Release.Name) $app.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $app.ingress.host }}
http:
paths:
# The application serves both the client and /api/v1 from one origin, so
# a single root rule replaces the separate client and API ingresses. No
# CORS annotations are needed now that the browser is same-origin.
- path: /
pathType: Prefix
backend:
service:
name: checkmate
port:
number: {{ $app.port }}
{{- end }}
@@ -1,10 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate
spec:
selector:
app: checkmate
ports:
- port: {{ $app.port }}
@@ -0,0 +1,46 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-client
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-client
template:
metadata:
labels:
app: checkmate-client
spec:
{{- with .Values.client.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.client.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: client
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: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
{{- with .Values.client.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
volumes:
- name: config-volume
configMap:
name: checkmate-client-nginx-cm
@@ -0,0 +1,31 @@
{{- if .Values.client.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-client-ingress
{{- if .Values.client.ingress.annotations }}
annotations:
{{- range $key, $value := .Values.client.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ .Values.client.ingress.className }}
{{- if .Values.client.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.client.ingress.host }}
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.client.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkmate-client
port:
number: {{ .Values.client.port }}
{{- end }}
@@ -0,0 +1,42 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: checkmate-client-nginx-cm
data:
default.conf: |
server {
listen 80;
listen [::]:80;
server_name checkmate-demo.bluewavelabs.ca;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# location /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;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
location /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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-client
spec:
selector:
app: checkmate-client
ports:
- port: {{ .Values.client.port }}
+36 -32
View File
@@ -1,45 +1,49 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- if eq $app.ingress.host "change_me" }}
{{- fail "app.ingress.host must be overridden and not set to 'change_me'" }}
{{- $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 }}
{{- $protocol := $app.protocol }}
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }}
{{- fail "app.protocol must be either 'http' or 'https'" }}
{{- 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 }}
{{- $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 }}
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
{{- fail "client.protocol must be either 'http' or 'https'" }}
{{- end }}
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
{{- if and $app.ingress.tls.enabled (ne $protocol "https") }}
{{- fail "app.ingress.tls.enabled is true but app.protocol is not 'https'. Set app.protocol: https to avoid mixed content." }}
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- 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 $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) */}}
{{- $ann := $app.ingress.annotations | default dict }}
{{- $hasIssuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") }}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }}
{{- if and $app.ingress.tls.enabled (not $hasIssuer) }}
{{- fail "app.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "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 }}
{{/*
Upgrade guard. The client and API are one service now, so a values file that still
points the two at different hosts cannot be honoured — fail loudly rather than
silently dropping one of them.
*/}}
{{- if and .Values.client .Values.api }}
{{- $clientHost := (.Values.client.ingress | default dict).host }}
{{- $apiHost := (.Values.api.ingress | default dict).host }}
{{- if and $clientHost $apiHost (ne $clientHost $apiHost) }}
{{- fail (printf "client.ingress.host (%s) and api.ingress.host (%s) differ, but Checkmate now serves the client and the API from one origin. Set a single app.ingress.host instead." $clientHost $apiHost) }}
{{- end }}
{{- end }}
{{/*
The client image is no longer published. Point this out rather than letting the
install fail later on an ImagePullBackOff nobody can action.
*/}}
{{- if and .Values.client .Values.client.image }}
{{- fail "client.image is set, but the separate client image is no longer published. Remove the client block; the application image in app.image now serves the client." }}
{{- 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 }}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
@@ -47,4 +51,4 @@
{{- 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>-tls in templates */}}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -1,4 +1,4 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
@@ -27,8 +27,8 @@ spec:
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $app.image) "tag" (.Values.worker.tag | default $app.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $app.imagePullPolicy }}
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 }}
@@ -36,13 +36,9 @@ spec:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# validateEnv requires a URL-shaped CLIENT_HOST, so mirror the app's default
# here. Omitted when secrets.CLIENT_HOST is set, since `env` would otherwise
# take precedence over the envFrom value and silently discard the override.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
{{- end }}
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
+35 -24
View File
@@ -1,17 +1,31 @@
# Checkmate is published as a single all-in-one application image: the API server
# also serves the React client, so one deployment and one ingress cover both.
# The `client:` and `api:` blocks from chart 0.2.x are still read for back-compat
# (see templates/_helpers.tpl) — `app.*` is the block to set on new installs.
app:
image: ghcr.io/bluewave-labs/checkmate
client:
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:
enabled: true
host: change_me
className: nginx
annotations: {}
# Example annotations for cert-manager:
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
# Note: when enabling TLS, also set client.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
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
resources: {}
affinity: {}
tolerations: {}
# Liveness/readiness probes hit GET /api/v1/health on the app port (proves the HTTP server is up).
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
@@ -33,27 +47,25 @@ app:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-tls # Optional; defaults to <release>-tls if omitted
# Note: when enabling TLS, also set app.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
# Upstream MongoDB. The former ghcr.io/bluewave-labs/checkmate-mongo image is no
# longer published; this matches the mongo version in docker/docker-compose.yaml.
mongodb:
image: mongo
tag: "8.0"
imagePullPolicy: IfNotPresent
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 app stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# app is all-in-one and processes jobs itself.
# 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 app.image when empty, same as the application image
tag: "" # defaults to app.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to app.imagePullPolicy when empty
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
@@ -85,8 +97,7 @@ worker:
secrets:
JWT_SECRET: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
# CLIENT_HOST is derived from app.protocol + app.ingress.host by the deployment;
# set it here only to override that (e.g. when fronting the app with another proxy).
CLIENT_HOST: change_me
NODE_ENV: production
LOG_LEVEL: info
TOKEN_TTL: 99d
-1
View File
@@ -4,7 +4,6 @@
"version": "3.11.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+1 -11
View File
@@ -2,13 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed";
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
interface TableState {
rowsPerPage: number;
@@ -20,7 +14,6 @@ interface SidebarState {
interface UIState {
monitors: TableState;
pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
@@ -44,9 +37,6 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
pagespeed: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const c = useMonitorListController({
types: ["hardware"],
checksLimit: 1,
refreshInterval: 5000,
rowsPerPageTable: "infrastructure",
rowsPerPageDefault: 5,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure"
actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<InfraMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
} = useGet<AppSettingsResponse>("/settings");
} = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({
types: ["pagespeed"],
checksLimit: 25,
refreshInterval: 30000,
rowsPerPageTable: "pagespeed",
rowsPerPageDefault: 10,
initialSortField: "name",
});
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const [sortField, setSortField] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed"
actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
>
<PageSpeedMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
<HeaderCreate
path="/pagespeed/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -113,7 +113,6 @@ const StatusPageView = () => {
apiUrl,
{},
{
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
}
);
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -34,9 +34,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -327,12 +327,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "3px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -347,11 +344,8 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
@@ -38,9 +38,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -272,11 +272,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -288,11 +285,8 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
@@ -1,6 +1,6 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -37,9 +37,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -83,14 +83,13 @@ export const modernStyles = (
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
empty: tokens.border,
};
@@ -104,7 +103,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
};
return {
@@ -355,19 +354,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind, severity = 1) => {
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
return {
borderRadius: "3px",
background:
kind === "degraded"
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
@@ -377,11 +370,8 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
@@ -37,9 +37,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -296,12 +296,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "2px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +312,8 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
@@ -59,9 +59,9 @@ export interface BaseStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -18,7 +18,6 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
export interface ChartCell {
key: string;
severity: number;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
@@ -29,22 +28,9 @@ export interface ChartCell {
const DAY_DOWN_THRESHOLD = 0.5;
// Color interpolation
// 0 = perfect
// 1 = at or below the down threshold
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
export const severityFromUpRatio = (upRatio: number): number => {
const failed = 1 - upRatio;
if (failed <= 0) return 0;
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
const clamped = Math.min(1, Math.max(0, scaled));
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
};
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
if (bucket.totalChecks === 0) return "empty";
const upRatio = bucket.upChecks / bucket.totalChecks;
if (upRatio >= 1) return "up";
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
return "degraded";
@@ -69,7 +55,6 @@ const emptyCell = (key: string): ChartCell => ({
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -91,7 +76,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
responseTime: check.responseTime ?? 0,
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
@@ -109,17 +93,9 @@ export const dailyBucketsToCells = (
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
const barKind = classifyDay(bucket);
return {
key: `day-${date}`,
severity:
barKind === "degraded"
? severityFromUpRatio(upRatio)
: barKind === "down"
? 1
: 0,
barKind,
heatKind:
barKind === "up"
@@ -10,7 +10,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
}
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
placement="top"
>
<Box
sx={cellSx(cell.heatKind, cell.severity)}
sx={cellSx(cell.heatKind)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -13,7 +13,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
sx={barSx(cell.barKind, cell.heightPct)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
editorial,
minimal,
};
// Interpolate between a theme's up and degraded colors by severity (0..1).
export const mixSeverityColor = (
up: string,
degraded: string,
severity: number
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
+16 -3
View File
@@ -34,6 +34,17 @@ import { Typography } from "@mui/material";
const certificateDateFormat = "MMM D, YYYY h A";
const isHttpsUrl = (url?: string): boolean => {
if (!url) {
return false;
}
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
};
interface CertificateResponse {
certificateDate: string;
}
@@ -80,13 +91,15 @@ const UptimeDetailsPage = () => {
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
// Certificate fetch - only for HTTP monitors
// Certificate fetch - only for monitors served over HTTPS. A "http" type
// monitor may still have an http:// URL, which serves no certificate, so the
// scheme is gated here to match the server rather than requesting a 400.
const certificateUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http") {
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
return null;
}
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
}, [monitorId, monitor?.type, monitor?.url]);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
+280 -29
View File
@@ -1,43 +1,294 @@
import { MonitorListPage } from "@/Components/monitors";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import Typography from "@mui/material/Typography";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { SPACING } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const c = useMonitorListController({
types: "selectable",
checksLimit: 25,
refreshInterval: 5000,
rowsPerPageTable: "monitors",
rowsPerPageDefault: 10,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
);
// Local state
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const {
monitors: monitorsWithChecks,
summary,
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
// Handlers
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="uptime"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="uptime"
actionLink="/uptime/create"
controller={c}
bulkActions
>
<MonitorTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/uptime/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<MonitorTable
monitors={monitorsWithChecks || []}
tags={tags || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setRowsPerPage={(rowsPerPage: number) => {
dispatch(
setRowsPerPage({
value: rowsPerPage,
table: "monitors",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+1 -6
View File
@@ -69,12 +69,7 @@ const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
proxyMode: z.enum(ProxyModes),
// The form uses "" for "no proxy selected". drop it from the payload so the
// server never receives an empty ObjectId
proxyId: z
.string()
.transform((value) => (value === "" ? undefined : value))
.optional(),
proxyId: z.string().optional(),
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
-3
View File
@@ -2,7 +2,6 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit";
import authReducer from "@/Features/Auth/authSlice";
import uiReducer from "@/Features/UI/uiSlice";
import storage from "redux-persist/lib/storage";
import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import {
persistReducer,
persistStore,
@@ -25,8 +24,6 @@ const persistConfig = {
storage,
whitelist: ["auth", "ui"],
transforms: [authTransform],
// Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value
stateReconciler: autoMergeLevel2,
};
const rootReducer = combineReducers({
+19 -24
View File
@@ -194,6 +194,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -828,6 +829,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -874,6 +876,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -889,29 +892,6 @@
"kuler": "^2.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -919,7 +899,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3144,6 +3123,7 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -3297,6 +3277,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -3512,6 +3493,7 @@
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0",
@@ -4101,6 +4083,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4465,6 +4448,7 @@
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -4743,6 +4727,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@@ -5655,6 +5640,7 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz",
"integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.17",
"lilconfig": "^3.1.3"
@@ -6439,6 +6425,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6883,6 +6870,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -8377,6 +8365,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -8399,6 +8388,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -8695,6 +8685,7 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -11907,6 +11898,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
@@ -14211,6 +14203,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14438,6 +14431,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15210,6 +15204,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
-1
View File
@@ -5,7 +5,6 @@
"main": "index.js",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
+14 -1
View File
@@ -7,10 +7,23 @@ import { parse as parseDomain } from "tldts";
type SSLCheckerType = typeof sslChecker;
type WhoisModule = typeof whoiser;
const DEFAULT_HTTPS_PORT = 443;
// Ports are user-defined, so a probe can dial something that accepts the
// connection and never completes the handshake. Bound the wait explicitly.
const CERTIFICATE_TIMEOUT_MS = 10_000;
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const monitorUrl = new URL(monitor.url);
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
// caller gets this instead of a raw OpenSSL handshake error from the socket.
if (monitorUrl.protocol !== "https:") {
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
}
const hostname = monitorUrl.hostname;
const cert = await checker(hostname);
// URL.port is "" for the scheme's default port, so fall back to 443; without
// this the certificate of a monitor on a non-standard port was probed on 443.
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
+4 -11
View File
@@ -16,13 +16,6 @@ import { DateRanges, SortOrders } from "@/types/query.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
// The client form submits proxyId: "" when no proxy is selected, set it to undefined
const proxyIdValidation = z
.string()
.optional()
.transform((value) => (value === "" ? undefined : value))
.refine((value) => value === undefined || /^[0-9a-f]{24}$/i.test(value), { message: "Invalid proxy ID" });
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
@@ -129,7 +122,7 @@ export const createMonitorBodyValidation = z
url: z.string().min(1, "URL is required"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
isActive: z.boolean().optional(),
@@ -178,7 +171,7 @@ export const editMonitorBodyValidation = z
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: proxyIdValidation,
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -240,7 +233,7 @@ const importedMonitorSchema = z
type: z.enum(MonitorTypes, "Invalid monitor type"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -313,7 +306,7 @@ export const monitorResponseSchema = z
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
proxyMode: z.enum(ProxyModes),
proxyId: proxyIdValidation,
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
updateStatusWindowAndChecks(
monitorId: string,
@@ -177,14 +177,16 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>, options?: { unsetProxyId?: boolean }) => {
const fields: Partial<Monitor> = { ...patch };
const update: Record<string, unknown> = { $set: fields };
if (options?.unsetProxyId) {
delete fields.proxyId;
update.$unset = { proxyId: "" };
}
const updatedMonitor = await MonitorModel.findOneAndUpdate({ _id: monitorId, teamId }, update, { new: true, runValidators: true });
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
{
$set: {
...patch,
},
},
{ new: true, runValidators: true }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
}
+1 -10
View File
@@ -133,10 +133,6 @@ export class MonitorService implements IMonitorService {
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
@@ -379,12 +375,7 @@ export class MonitorService implements IMonitorService {
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
// Moving off custom mode orphans the stored proxyId. Unset it so the stale reference doesn't block deleting that proxy
const unsetProxyId = body.proxyMode !== undefined && body.proxyMode !== "custom";
if (unsetProxyId) {
delete body.proxyId;
}
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
};
+3
View File
@@ -12,6 +12,9 @@ declare module "ssl-checker" {
export interface SSLOptions {
method?: "GET" | "HEAD";
port?: number;
// Inherited from https.RequestOptions; ssl-checker rejects with "Timed Out"
// when the socket exceeds it.
timeout?: number;
agent?: Agent;
rejectUnauthorized?: boolean;
validateSubjectAltName?: boolean;
@@ -124,38 +124,6 @@ describe("MonitorService", () => {
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
});
it("strips a stray proxyId when proxyMode is not custom", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service } = createService({ monitorsRepository });
await service.createMonitor(TEAM_ID, USER_ID, {
...monitor,
proxyMode: "inherit",
proxyId: "64b7a1f2c9e77a001a2b3c4d",
} as any);
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
expect(createdBody.proxyId).toBeUndefined();
});
it("keeps proxyId when proxyMode is custom", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor();
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
const { service } = createService({ monitorsRepository });
await service.createMonitor(TEAM_ID, USER_ID, {
...monitor,
proxyMode: "custom",
proxyId: "64b7a1f2c9e77a001a2b3c4d",
} as any);
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
expect(createdBody.proxyId).toBe("64b7a1f2c9e77a001a2b3c4d");
});
it("throws when repository returns null", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.create as jest.Mock).mockResolvedValue(null);
@@ -782,42 +750,9 @@ describe("MonitorService", () => {
const result = await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { name: "Updated" } });
expect(result).toMatchObject({ name: "Updated" });
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }, { unsetProxyId: false });
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" });
expect(jobQueue.updateJob).toHaveBeenCalledWith(updatedMonitor);
});
it("keeps proxyId and does not unset it when proxyMode is custom", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
const { service } = createService({ monitorsRepository });
await service.editMonitor({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
body: { proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
});
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
MONITOR_ID,
TEAM_ID,
{ proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" },
{ unsetProxyId: false }
);
});
it("strips proxyId and unsets it when proxyMode moves off custom", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
const { service } = createService({ monitorsRepository });
await service.editMonitor({
teamId: TEAM_ID,
monitorId: MONITOR_ID,
body: { proxyMode: "inherit", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
});
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { proxyMode: "inherit" }, { unsetProxyId: true });
});
});
describe("updateNotifications", () => {
@@ -0,0 +1,101 @@
import { describe, expect, it } from "@jest/globals";
import { fetchMonitorCertificate } from "../../../src/api/controllers/controllerUtils.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { SSLDetails, SSLOptions } from "ssl-checker";
const VALID_CERT: SSLDetails = {
daysRemaining: 30,
valid: true,
validFrom: "2026-01-01T00:00:00.000Z",
validTo: "2026-12-31T00:00:00.000Z",
validFor: ["example.com"],
};
/** Records the arguments the checker was called with. */
const spyChecker = (cert: SSLDetails = VALID_CERT) => {
const calls: Array<{ hostname: string; options?: SSLOptions }> = [];
const checker = (async (hostname: string, options?: SSLOptions) => {
calls.push({ hostname, options });
return cert;
}) as unknown as typeof import("ssl-checker").default;
return { checker, calls };
};
const monitorWithUrl = (url: string) => ({ url }) as Monitor;
describe("fetchMonitorCertificate", () => {
it("probes the port named in the monitor URL", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
expect(calls).toHaveLength(1);
expect(calls[0].hostname).toBe("example.com");
expect(calls[0].options?.port).toBe(54321);
});
it("falls back to 443 when the URL names no port", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
expect(calls[0].options?.port).toBe(443);
});
it("passes an explicit 443 through unchanged", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:443"));
expect(calls[0].options?.port).toBe(443);
});
it("bounds the probe with a timeout so a user-defined port cannot hang it", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
expect(calls[0].options?.timeout).toBe(10_000);
});
it("never passes NaN as the port", async () => {
const { checker, calls } = spyChecker();
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
expect(Number.isNaN(calls[0].options?.port)).toBe(false);
});
it("rejects a plain-HTTP monitor without opening a TLS connection", async () => {
const { checker, calls } = spyChecker();
// Probing a non-TLS port would surface a raw OpenSSL handshake error.
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com:8080"))).rejects.toThrow(
"Certificate is only available for HTTPS monitors"
);
expect(calls).toHaveLength(0);
});
it("rejects a plain-HTTP monitor on the default port", async () => {
const { checker, calls } = spyChecker();
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com"))).rejects.toThrow(
"Certificate is only available for HTTPS monitors"
);
expect(calls).toHaveLength(0);
});
it("returns the certificate details it received", async () => {
const { checker } = spyChecker();
const cert = await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"));
expect(cert.validTo).toBe(VALID_CERT.validTo);
});
it("throws when the certificate has no expiry", async () => {
const { checker } = spyChecker({ ...VALID_CERT, validTo: undefined as unknown as string });
await expect(fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"))).rejects.toThrow("Certificate not found");
});
});
@@ -477,7 +477,6 @@ describe("monitorValidation — proxy fields", () => {
type: "http" as const,
url: "https://example.com",
};
const PROXY_ID = "64b7a1f2c9e77a001a2b3c4d";
describe("createMonitorBodyValidation", () => {
it("defaults proxyMode to inherit", () => {
@@ -488,38 +487,23 @@ describe("monitorValidation — proxy fields", () => {
});
it("accepts custom mode with a proxyId", () => {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: PROXY_ID });
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe("custom");
expect(parsed.proxyId).toBe(PROXY_ID);
expect(parsed.proxyId).toBe("proxy-1");
});
it("rejects custom mode without a proxyId", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
});
it("rejects custom mode with an empty-string proxyId", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "" })).toThrow();
});
it("normalizes an empty-string proxyId to undefined on non-custom modes", () => {
for (const proxyMode of ["inherit", "none"] as const) {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "" });
expect(parsed.proxyId).toBeUndefined();
}
});
it("rejects a proxyId that is not a valid ObjectId", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" })).toThrow();
});
it("rejects an unknown proxyMode", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
});
it("allows a stray proxyId on non-custom modes", () => {
for (const proxyMode of ["inherit", "none"] as const) {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: PROXY_ID });
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe(proxyMode);
}
});
@@ -537,19 +521,9 @@ describe("monitorValidation — proxy fields", () => {
});
it("accepts custom mode with a proxyId", () => {
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: PROXY_ID });
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyId).toBe(PROXY_ID);
});
it("normalizes an empty-string proxyId to undefined", () => {
const parsed = editMonitorBodyValidation.parse({ proxyMode: "inherit", proxyId: "" });
expect(parsed.proxyId).toBeUndefined();
});
it("rejects a proxyId that is not a valid ObjectId", () => {
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "not-an-object-id" })).toThrow();
expect(parsed.proxyId).toBe("proxy-1");
});
});