diff --git a/CLAUDE.md b/CLAUDE.md index 63a349ae7..1adfd703f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,19 +68,23 @@ VITE_APP_LOG_LEVEL="debug" ### Backend Layers ``` server/src/ -├── controllers/ # Route handlers (authController, monitorController, etc.) -├── service/ # Business logic -│ ├── business/ # Core monitoring logic -│ ├── infrastructure/ # Server/system utilities -│ └── system/ # App-level settings +├── api/ # HTTP layer +│ ├── controllers/ # Route handlers (authController, monitorController, etc.) +│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler +│ ├── routes/ # API route definitions +│ └── validation/ # Zod request-payload schemas +├── domain/ # Business logic + data access, one folder per entity +│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts +│ ├── checks/ +│ ├── incidents/ +│ └── … # users, teams, status-pages, notifications, etc. +├── service/ # Cross-cutting services (e.g. infrastructure/) +├── config/ # App config wiring (controllers, routes, services, envValidation) ├── db/ -│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.) -│ ├── migration/ # Database migrations (run on startup) -│ └── modules/ # Database-specific modules -├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler -├── routes/v1/ # API route definitions -├── validation/ # Joi input validation schemas -└── repositories/ # Data access layer +│ └── migration/ # Database migrations (run on startup) +├── templates/ # Email/notification templates +├── types/ # Shared const tuples + derived types +└── utils/ # Shared utilities ``` ### Frontend Structure diff --git a/charts/helm/checkmate/Chart.yaml b/charts/helm/checkmate/Chart.yaml index 97f320e68..c9433f690 100644 --- a/charts/helm/checkmate/Chart.yaml +++ b/charts/helm/checkmate/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: checkmate-chart description: A Helm chart for Checkmate App type: application -version: 0.1.0 -appVersion: "2.3" +version: 0.2.0 +appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1) diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index 06cf9f7b9..6bc1f3ecd 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate ### 2. Customize values.yaml Edit `values.yaml` to update: -- `client.ingress.host` and `server.ingress.host` with your domain names -- `server.protocol` (usually http or https) +- `client.ingress.host` and `api.ingress.host` with your domain names +- `api.protocol` (usually http or https) - **If upgrading**: Migrate persistence settings from flat structure to nested: - Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - - Old: `persistence.redisSize` → New: `persistence.redis.size` - - Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default) + - Add: `persistence.mongo.storageClass` (leave empty for default) - Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values - **For TLS/HTTPS**: Configure ingress TLS settings (see section below) @@ -32,7 +31,7 @@ Edit `values.yaml` to update: ```bash helm install checkmate ./charts/helm/checkmate ``` -This will deploy the client, server, MongoDB, and Redis components. +This will deploy the client, api, and MongoDB components. ### 4. Verify the deployment Check pods and services: @@ -43,6 +42,137 @@ kubectl get svc Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. +## Upgrading from a previous release + +This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis. +Read this before running `helm upgrade` on an existing install. + +### `server.*` → `api.*` (values rename) + +The value block formerly named `server` is now `api` (`server.image` → `api.image`, +`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`, +`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.). + +**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on +top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The +shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:` +block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins — +don't mix them. + +### Resource renames cause a replace, not a rename + +The Kubernetes objects are renamed alongside the values: + +| Old object | New object | +| --- | --- | +| Deployment/Service `checkmate-server` | `checkmate-api` | +| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` | +| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` | + +Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a +**new ClusterIP** for the API Service — update anything outside the chart that referenced the +`checkmate-server` Service by name. MongoDB data (its PVC) is untouched. + +### Redis removed + +The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the +scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are +unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are +deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:` +keys in your values file are harmless (ignored). + +### New secret defaults + +`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets` +block and merge into your install unless you override them. Set them explicitly if you relied on +different values (e.g. a non-production `NODE_ENV`). + +### Image tags follow the chart's `appVersion` + +Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's +`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the +chart. You can still pin per tier: + +```yaml +api: + image: ghcr.io/bluewave-labs/checkmate-backend # repo only + tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag +``` + +- `.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and + `api.image`) unless you set `worker.tag` / `worker.image`. +- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is + used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged. + +**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is +cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or +let it default to `appVersion`) for reproducible, rollback-able deploys. + +## Scaling: the worker tier & autoscaling + +By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring +jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated, +horizontally-scalable worker tier. + +### Topology + +| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) | +| --- | --- | --- | +| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed | +| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs | + +`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API +and worker tier can never both process (or both ignore) the queue. + +### Enabling the worker tier + +```yaml +worker: + enabled: true + replicas: 2 # used only when autoscaling.enabled = false +``` + +The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on +`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit. + +> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the +> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight +> checks. + +> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are +> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate +> this; full failover to the API would need leader election (not supported). + +### Autoscaling with KEDA + +Backlog-driven autoscaling is **off by default** and requires the +[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with: + +```yaml +worker: + enabled: true + autoscaling: + enabled: true + minReplicaCount: 1 # processing floor — never 0, or checks silently stall + maxReplicaCount: 10 + backlogPerReplica: 50 # target due-job backlog per worker + mongo: + dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING +``` + +A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker +Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment +omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName` +matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0 +backlog → never scales up). + +#### Fallback: Prometheus-adapter HPA (not shipped) + +If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape +the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target +it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes +`now` in code on this path, so the query coercion is unnecessary. + ## Enabling TLS/HTTPS with cert-manager If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers. @@ -68,7 +198,7 @@ client: enabled: true secretName: checkmate-client-tls -server: +api: protocol: https ingress: enabled: true @@ -78,7 +208,7 @@ server: cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: true - secretName: checkmate-server-tls + secretName: checkmate-api-tls ``` ### Alternative: Using --set flags @@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags: ```bash helm install checkmate ./charts/helm/checkmate \ --set client.protocol=https \ - --set server.protocol=https \ + --set api.protocol=https \ --set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ --set client.ingress.tls.enabled=true \ --set client.ingress.tls.secretName=checkmate-client-tls \ - --set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ - --set server.ingress.tls.enabled=true \ - --set server.ingress.tls.secretName=checkmate-server-tls + --set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ + --set api.ingress.tls.enabled=true \ + --set api.ingress.tls.secretName=checkmate-api-tls ``` ### Verification @@ -107,7 +237,7 @@ kubectl get certificates # Check certificate details kubectl describe certificate checkmate-client-tls -kubectl describe certificate checkmate-server-tls +kubectl describe certificate checkmate-api-tls # Verify the secrets were created kubectl get secrets | grep checkmate-tls diff --git a/charts/helm/checkmate/templates/_helpers.tpl b/charts/helm/checkmate/templates/_helpers.tpl new file mode 100644 index 000000000..69a1b259b --- /dev/null +++ b/charts/helm/checkmate/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{- /* +Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that +still pass a legacy `server:` block keep working — this returns the effective API values with any +legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set). + +New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as: + {{- $api := include "checkmate.api" . | fromYaml -}} +then reference `$api.*` instead of `.Values.api.*`. +*/}} +{{- define "checkmate.api" -}} +{{- $api := deepCopy .Values.api -}} +{{- with .Values.server -}} +{{- $api = mergeOverwrite $api (deepCopy .) -}} +{{- end -}} +{{- $api | toYaml -}} +{{- end -}} + +{{- /* +Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of +truth for tags: bumping Chart.appVersion moves every tier at once. Call as: + {{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }} + +If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps +legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest"). +A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still +appended. Otherwise the tag is `tag` if set, else the chart's appVersion. +*/}} +{{- define "checkmate.image" -}} +{{- $lastSegment := .image | splitList "/" | last -}} +{{- if contains ":" $lastSegment -}} +{{- .image -}} +{{- else -}} +{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml new file mode 100644 index 000000000..6469d3bdd --- /dev/null +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -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 }} diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/api/ingress.yaml similarity index 61% rename from charts/helm/checkmate/templates/server-ingress.yaml rename to charts/helm/checkmate/templates/api/ingress.yaml index 34c9ac0dc..ae60889f2 100644 --- a/charts/helm/checkmate/templates/server-ingress.yaml +++ b/charts/helm/checkmate/templates/api/ingress.yaml @@ -1,11 +1,12 @@ -{{- if .Values.server.ingress.enabled }} +{{- $api := include "checkmate.api" . | fromYaml -}} +{{- if $api.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: checkmate-server-ingress - {{- if .Values.server.ingress.annotations }} + name: checkmate-api-ingress + {{- if $api.ingress.annotations }} annotations: - {{- range $key, $value := .Values.server.ingress.annotations }} + {{- range $key, $value := $api.ingress.annotations }} {{ $key }}: {{ $value | quote }} {{- end }} {{- end }} @@ -17,22 +18,22 @@ metadata: #nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" #nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}} spec: - ingressClassName: {{ .Values.server.ingress.className }} - {{- if .Values.server.ingress.tls.enabled }} + ingressClassName: {{ $api.ingress.className }} + {{- if $api.ingress.tls.enabled }} tls: - hosts: - - {{ .Values.server.ingress.host }} - secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }} + - {{ $api.ingress.host }} + secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }} {{- end }} rules: - - host: {{ .Values.server.ingress.host }} + - host: {{ $api.ingress.host }} http: paths: - path: /api/v1 pathType: Prefix backend: service: - name: checkmate-server + name: checkmate-api port: - number: {{ .Values.server.port }} + number: {{ $api.port }} {{- end }} diff --git a/charts/helm/checkmate/templates/api/service.yaml b/charts/helm/checkmate/templates/api/service.yaml new file mode 100644 index 000000000..dfea901c4 --- /dev/null +++ b/charts/helm/checkmate/templates/api/service.yaml @@ -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 }} diff --git a/charts/helm/checkmate/templates/client-deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml similarity index 76% rename from charts/helm/checkmate/templates/client-deployment.yaml rename to charts/helm/checkmate/templates/client/deployment.yaml index 381631ef5..a98e6a648 100644 --- a/charts/helm/checkmate/templates/client-deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -22,12 +23,13 @@ spec: {{- end }} containers: - name: client - image: {{ .Values.client.image }} + image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }} + imagePullPolicy: {{ .Values.client.imagePullPolicy }} ports: - containerPort: {{ .Values.client.port }} env: - name: UPTIME_APP_API_BASE_URL - value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1" + value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1" - name: UPTIME_APP_CLIENT_HOST value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" volumeMounts: @@ -41,4 +43,4 @@ spec: volumes: - name: config-volume configMap: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm diff --git a/charts/helm/checkmate/templates/client-ingress.yaml b/charts/helm/checkmate/templates/client/ingress.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-ingress.yaml rename to charts/helm/checkmate/templates/client/ingress.yaml diff --git a/charts/helm/checkmate/templates/server-nginx-cm.yaml b/charts/helm/checkmate/templates/client/nginx-cm.yaml similarity index 81% rename from charts/helm/checkmate/templates/server-nginx-cm.yaml rename to charts/helm/checkmate/templates/client/nginx-cm.yaml index 84da55299..0d23aac0a 100644 --- a/charts/helm/checkmate/templates/server-nginx-cm.yaml +++ b/charts/helm/checkmate/templates/client/nginx-cm.yaml @@ -1,7 +1,8 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: v1 -kind: ConfigMap +kind: ConfigMap metadata: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm data: default.conf: | server { @@ -22,7 +23,7 @@ data: } # location /api/ { - # proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/; + # proxy_pass http://{{ $api.ingress.host }}:5000/api/; # proxy_http_version 1.1; # proxy_set_header Host $host; # proxy_set_header X-Real-IP $remote_addr; @@ -31,7 +32,7 @@ data: # } location /api-docs/ { - proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/; + proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/charts/helm/checkmate/templates/client-service.yaml b/charts/helm/checkmate/templates/client/service.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-service.yaml rename to charts/helm/checkmate/templates/client/service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-service.yaml b/charts/helm/checkmate/templates/db/mongodb-service.yaml similarity index 100% rename from charts/helm/checkmate/templates/mongodb-service.yaml rename to charts/helm/checkmate/templates/db/mongodb-service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-statefulsets.yaml b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml similarity index 75% rename from charts/helm/checkmate/templates/mongodb-statefulsets.yaml rename to charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml index 8149b77e2..16c228c2b 100644 --- a/charts/helm/checkmate/templates/mongodb-statefulsets.yaml +++ b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml @@ -22,7 +22,8 @@ spec: {{- end }} containers: - name: mongodb - image: {{ .Values.mongodb.image }} + image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }} + imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }} ports: - containerPort: {{ .Values.mongodb.port }} command: ["mongod", "--quiet", "--bind_ip_all"] @@ -37,7 +38,9 @@ spec: - metadata: name: checkmate-mongo-persistent-storage spec: - storageClassName: {{ .Values.persistence.mongo.storageClass | quote }} + {{- with .Values.persistence.mongo.storageClass }} + storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}} + {{- end }} accessModes: ["ReadWriteOnce"] resources: requests: diff --git a/charts/helm/checkmate/templates/prechecks.yaml b/charts/helm/checkmate/templates/prechecks.yaml index e2b9ec5c2..cc9f6b9a1 100644 --- a/charts/helm/checkmate/templates/prechecks.yaml +++ b/charts/helm/checkmate/templates/prechecks.yaml @@ -1,14 +1,15 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} {{- if eq .Values.client.ingress.host "change_me" }} {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} {{- end }} -{{- if eq .Values.server.ingress.host "change_me" }} - {{- fail "server.ingress.host must be overridden and not set to 'change_me'" }} +{{- if eq $api.ingress.host "change_me" }} + {{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }} {{- end }} -{{- $serverProtocol := .Values.server.protocol }} -{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }} - {{- fail "server.protocol must be either 'http' or 'https'" }} +{{- $apiProtocol := $api.protocol }} +{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }} + {{- fail "api.protocol must be either 'http' or 'https'" }} {{- end }} {{- $clientProtocol := .Values.client.protocol }} @@ -21,28 +22,33 @@ {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }} - {{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }} +{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }} + {{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }} {{- end }} {{/* If client runs on https, API must also be https to avoid mixed content */}} -{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }} - {{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }} +{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }} + {{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }} {{- end }} {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{- $cAnn := .Values.client.ingress.annotations | default dict }} -{{- $sAnn := .Values.server.ingress.annotations | default dict }} +{{- $aAnn := $api.ingress.annotations | default dict }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }} -{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }} +{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }} {{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }} {{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }} - {{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} +{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }} + {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{/* Secret name can be omitted; we default to -client|server-tls in templates */}} +{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}} +{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }} + {{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }} +{{- end }} + +{{/* Secret name can be omitted; we default to -client|api-tls in templates */}} diff --git a/charts/helm/checkmate/templates/redis-service.yaml b/charts/helm/checkmate/templates/redis-service.yaml deleted file mode 100644 index 2720350da..000000000 --- a/charts/helm/checkmate/templates/redis-service.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: checkmate-redis -spec: - selector: - app: checkmate-redis - ports: - - port: {{ .Values.redis.port }} -{{- end }} diff --git a/charts/helm/checkmate/templates/redis-statefulsets.yaml b/charts/helm/checkmate/templates/redis-statefulsets.yaml deleted file mode 100644 index acf7c85a8..000000000 --- a/charts/helm/checkmate/templates/redis-statefulsets.yaml +++ /dev/null @@ -1,33 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: checkmate-redis -spec: - replicas: 1 - selector: - matchLabels: - app: checkmate-redis - template: - metadata: - labels: - app: checkmate-redis - spec: - containers: - - name: redis - image: {{ .Values.redis.image }} - ports: - - containerPort: {{ .Values.redis.port }} - volumeMounts: - - name: redis-data - mountPath: /data - volumeClaimTemplates: - - metadata: - name: checkmate-redis-persistent-storage - spec: - storageClassName: {{ .Values.persistence.redis.storageClass | quote }} - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }} -{{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/secrets.yaml b/charts/helm/checkmate/templates/secrets.yaml index af104e7d1..9c99395f4 100644 --- a/charts/helm/checkmate/templates/secrets.yaml +++ b/charts/helm/checkmate/templates/secrets.yaml @@ -4,6 +4,14 @@ {{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }} {{- end }} +{{- /* + The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it + by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there, + so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so + the one connection string resolves from every namespace. External/already-qualified hosts are not + matched (the char after the host name is "." for an FQDN, which the pattern excludes). +*/}} +{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }} apiVersion: v1 kind: Secret metadata: @@ -11,5 +19,9 @@ metadata: type: Opaque stringData: {{- range $key, $value := $secrets }} +{{- if eq $key "DB_CONNECTION_STRING" }} + {{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }} +{{- else }} {{ $key }}: {{ $value | quote }} +{{- end }} {{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server-deployment.yaml deleted file mode 100644 index 393964ccd..000000000 --- a/charts/helm/checkmate/templates/server-deployment.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: checkmate-server -spec: - replicas: 1 - selector: - matchLabels: - app: checkmate-server - template: - metadata: - labels: - app: checkmate-server - spec: - {{- with .Values.server.affinity }} - affinity: - {{- tpl ( . | toYaml) $ | nindent 8 }} - {{- end }} - {{- with .Values.server.tolerations }} - tolerations: - {{- tpl ( . | toYaml) $ | nindent 8 }} - {{- end }} - containers: - - name: server - image: {{ .Values.server.image }} - ports: - - containerPort: {{ .Values.server.port }} - envFrom: - - secretRef: - name: checkmate-secrets - {{- with .Values.server.resources }} - resources: - {{- tpl ( . | toYaml) $ | nindent 12 }} - {{- end }} - env: - - name: CLIENT_HOST - value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" diff --git a/charts/helm/checkmate/templates/server-service.yaml b/charts/helm/checkmate/templates/server-service.yaml deleted file mode 100644 index d8a7fbe86..000000000 --- a/charts/helm/checkmate/templates/server-service.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: checkmate-server -spec: - selector: - app: checkmate-server - ports: - - port: {{ .Values.server.port }} diff --git a/charts/helm/checkmate/templates/worker/deployment.yaml b/charts/helm/checkmate/templates/worker/deployment.yaml new file mode 100644 index 000000000..0b0849455 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/deployment.yaml @@ -0,0 +1,66 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} +{{- if .Values.worker.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: checkmate-worker +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count + {{- end }} + selector: + matchLabels: + app: checkmate-worker + template: + metadata: + labels: + app: checkmate-worker + spec: + terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }} + {{- with .Values.worker.affinity }} + affinity: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + containers: + - name: worker + image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }} + imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }} + ports: + - name: health + containerPort: {{ .Values.worker.healthPort }} + envFrom: + - secretRef: + name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv) + env: + # Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes. + - name: CLIENT_HOST + value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" + - name: QUEUE_MODE + value: "worker" + - name: HEALTH_PORT + value: {{ .Values.worker.healthPort | quote }} + livenessProbe: + httpGet: + path: /livez + port: health + initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }} + {{- with .Values.worker.resources }} + resources: + {{- tpl ( . | toYaml) $ | nindent 12 }} + {{- end }} +{{- end }} diff --git a/charts/helm/checkmate/templates/worker/scaledobject.yaml b/charts/helm/checkmate/templates/worker/scaledobject.yaml new file mode 100644 index 000000000..09fc9a8a3 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/scaledobject.yaml @@ -0,0 +1,30 @@ +{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: checkmate-worker +spec: + scaleTargetRef: + name: checkmate-worker + minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks + maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }} + cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }} + advanced: + horizontalPodAutoscalerConfig: + behavior: + scaleDown: + stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }} + triggers: + - type: mongodb + metadata: + dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }} + collection: {{ .Values.worker.autoscaling.mongo.collection | quote }} + # The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong): + query: >- + { "type": "check", "isActive": true, + "$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] }, + "$or": [ { "lockedUntil": null }, + { "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] } + queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }} + connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets +{{- end }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 9a586dd54..b2126fe20 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -1,5 +1,7 @@ client: - image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-client + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 80 protocol: http ingress: @@ -17,10 +19,24 @@ client: # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -server: - image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0 +api: + image: ghcr.io/bluewave-labs/checkmate-backend + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 52345 protocol: http + # Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up). + probes: + liveness: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 ingress: enabled: true host: change_me @@ -31,40 +47,62 @@ server: # cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: false - # secretName: {{ .Release.Name }}-server-tls # Optional; defaults to -server-tls if omitted - # Note: when enabling TLS, also set server.protocol: https and add + # secretName: {{ .Release.Name }}-api-tls # Optional; defaults to -api-tls if omitted + # Note: when enabling TLS, also set api.protocol: https and add # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -redis: - enabled: false - image: redis:7.2 - port: 6379 - mongodb: - image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-mongo + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 27017 +worker: + # Off by default; existing installs are unchanged. When true, a dedicated worker processes all + # jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the + # API is all-in-one and processes jobs itself. + enabled: false + image: "" # defaults to api.image when empty, same as API image + tag: "" # defaults to api.tag (-> Chart.appVersion) when empty + imagePullPolicy: "" # defaults to api.imagePullPolicy when empty + replicas: 1 # used only when autoscaling.enabled = false + healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics + terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain + resources: {} + affinity: {} + tolerations: {} + probes: + liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: # /readyz — flips to 503 on drain + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + autoscaling: + enabled: false # requires the KEDA operator installed in-cluster + minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks) + maxReplicaCount: 10 + backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker + cooldownPeriod: 300 # seconds before scaling an idle target down + stabilizationWindowSeconds: 300 # damp the interval-boundary herd + mongo: + dbName: uptime_db # must match DB_CONNECTION_STRING's database + collection: jobs + secrets: JWT_SECRET: change_me -# REFRESH_TOKEN_SECRET: change_me -# SYSTEM_EMAIL_ADDRESS: test@example.com -# SYSTEM_EMAIL_PASSWORD: change_me -# SYSTEM_EMAIL_HOST: smtp.example.com -# SYSTEM_EMAIL_PORT: "587" -# PAGESPEED_API_KEY: change_me - DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db + DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db CLIENT_HOST: change_me -# REDIS_HOST: redis -# REDIS_PORT: "6379" -# DB_TYPE: MongoDB -# TOKEN_TTL: 99d -# REFRESH_TOKEN_TTL: 99d + NODE_ENV: production + LOG_LEVEL: info + TOKEN_TTL: 99d persistence: mongo: size: 5Gi storageClass: "" - redis: - size: 1Gi - storageClass: "" \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index c13d482de..c7d59b06c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,13 +1,14 @@ { "name": "client", - "version": "3.8.1", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "3.8.1", + "version": "3.9.0", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", @@ -303,6 +304,25 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index 28a2a44c0..685b37db1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "3.8.1", + "version": "3.9.0", "type": "module", "scripts": { "dev": "vite", @@ -14,9 +14,10 @@ "format-check": "prettier --check ." }, "lint-staged": { - "**/*": "prettier --write" + "**/*": "prettier --write --ignore-unknown" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 13277be72..230e7650b 100644 --- a/client/src/Components/inputs/TextInput.tsx +++ b/client/src/Components/inputs/TextInput.tsx @@ -22,7 +22,7 @@ export const TextInput = forwardRef(function T width: "100%", "& .MuiOutlinedInput-root": { borderRadius: theme.shape.borderRadius, - height: 34, + height: props.multiline ? "auto" : 34, fontSize: typographyLevels.base, overflow: "hidden", }, diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 50fa62ec3..9c6200d22 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; -import { DefaultPageSpeedStrategy } from "@/Types/Monitor"; +import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; interface UseMonitorFormOptions { @@ -40,11 +40,13 @@ export const getMonitorDefaults = ( ...getGeoCheckDefaults(data), type: "http", url: data?.url || "", + method: data?.method ?? DefaultHttpMethod, ignoreTlsErrors: data?.ignoreTlsErrors || false, useAdvancedMatching: data?.useAdvancedMatching || false, matchMethod: data?.matchMethod || "", expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", + customUpCodes: data?.customUpCodes || [], }; break; case "ping": @@ -133,11 +135,13 @@ export const getMonitorDefaults = ( ...getGeoCheckDefaults(data), type: "http", url: "", + method: DefaultHttpMethod, ignoreTlsErrors: false, useAdvancedMatching: false, matchMethod: "", expectedValue: "", jsonPath: "", + customUpCodes: [], }; } diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 681a3da6c..609db4792 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -31,6 +31,7 @@ export const useStatusPageForm = ({ const defaults: StatusPageFormData = { companyName: data?.companyName || "", url: data?.url || generateDefaultUrl(), + customDomain: data?.customDomain ?? null, timezone: resolveTimezone(data?.timezone), type: data?.type || ["uptime"], color: data?.color || "#13715B", diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 94bed9355..96b05be71 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { useEffect } from "react"; import { logger } from "@/Utils/logger"; +import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -41,8 +42,11 @@ import { type Monitor, type MonitorType, type GamesMap, + type HttpMethod, supportsGeoCheck, DefaultPageSpeedStrategy, + DefaultHttpMethod, + HttpMethods, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -258,7 +262,7 @@ const CreateMonitorPage = () => { resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, handleSubmit, clearErrors, trigger, reset } = form; + const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; useEffect(() => { reset(defaults); @@ -270,6 +274,7 @@ const CreateMonitorPage = () => { const showStep = (step: number) => isEditMode || currentStep === step; const watchedType = watch("type") as MonitorType; + const watchedMethod = watch("method") as HttpMethod | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -1091,27 +1096,144 @@ const CreateMonitorPage = () => { rightContent={ ( - - field.onChange(e.target.checked)} - /> - + + + {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" + `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` )} )} /> - {watchedUseAdvancedMatching && ( + { + const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => + field.value?.includes(option.id) + ); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={( + _: unknown, + newValue: typeof ALL_HTTP_STATUS_CODES + ) => { + field.onChange(newValue?.map((v) => v.id) || []); + }} + fieldLabel={t( + "pages.createMonitor.form.advanced.option.customUpCodes.label" + )} + /> + + {t( + "pages.createMonitor.form.advanced.option.customUpCodes.description" + )} + + {selectedOptions.length > 0 && ( + + {selectedOptions.map((option, index) => ( + + {option.name} + { + field.onChange( + field.value?.filter((id) => id !== option.id) + ); + }} + aria-label={t( + "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" + )} + > + + + {index < selectedOptions.length - 1 && } + + ))} + + )} + + ); + }} + /> + {watchedMethod !== "HEAD" && ( + ( + + field.onChange(e.target.checked)} + /> + + {t( + "pages.createMonitor.form.advanced.option.advancedMatching.label" + )} + + + )} + /> + )} + {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( { title={t("pages.logs.diagnostics.mongoDBStats.readyState")} subtitle={mongoStats.readyState.toString()} /> + + + + + + row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now(); + const headers: Header[] = [ { id: "state", content: t("pages.logs.table.headers.state"), render: (row) => { - if (row.lockedAt) { + if (isRunning(row)) { return t("common.labels.running"); } if (row.monitorActive === true) { @@ -72,18 +75,6 @@ export const TableJobs = ({ ), }, - { - id: "url", - content: t("common.table.headers.url"), - render: (row) => ( - - {row.monitorUrl} - - ), - }, { id: "type", content: t("common.table.headers.type"), @@ -92,50 +83,36 @@ export const TableJobs = ({ { id: "interval", content: t("common.table.headers.interval"), - render: (row) => { - let interval; - if (row.monitorId.toString().includes("geo")) { - interval = row.monitorGeoInterval ?? 0; - } else { - interval = row.repeat ?? 0; - } - return {prettyMilliseconds(interval)}; - }, + render: (row) => ( + {prettyMilliseconds(row.monitorInterval ?? 0)} + ), }, { - id: "lastRun", - content: t("pages.logs.table.headers.lastRunAt"), + id: "lastFinishedAt", + content: t("pages.logs.table.headers.lastFinishedAt"), render: (row) => { - const v = formatTimestamp(row.lastRunAt) ?? "-"; + const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-"; return ( - {v} + {lastFinishedAt} ); }, }, { - id: "lastRunTook", - content: t("pages.logs.table.headers.lastRunTook"), + id: "nextScheduledAt", + content: t("pages.logs.table.headers.nextScheduledAt"), render: (row) => { - const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-"; - return {value}; - }, - }, - { - id: "lockedAt", - content: t("pages.logs.table.headers.lockedAt"), - render: (row) => { - const v = formatTimestamp(row.lockedAt) ?? "-"; + const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-"; return ( - {v} + {nextScheduledAt} ); }, @@ -169,7 +146,7 @@ export const TableJobs = ({ headers={headers} data={jobsWithId} getRowSx={(row) => ({ - ...(row.lockedAt && { + ...(isRunning(row) && { "& td": { backgroundColor: theme.palette.rowStatus.running }, }), ...(row.monitorActive === false && { diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index c9bc04838..8dd119b72 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -151,12 +151,18 @@ const CreateStatusPage = () => { fd.append("isPublished", String(data.isPublished)); if (data.companyName) fd.append("companyName", data.companyName); if (data.url) fd.append("url", data.url); + if (data.customDomain !== undefined && data.customDomain !== null) { + fd.append("customDomain", data.customDomain); + } else { + fd.append("customDomain", ""); + } if (data.timezone) fd.append("timezone", data.timezone); if (data.color) fd.append("color", data.color); fd.append("showCharts", String(data.showCharts)); fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + fd.append("customCSS", data.customCSS ?? ""); if (data.theme) fd.append("theme", data.theme); if (data.themeMode) fd.append("themeMode", data.themeMode); @@ -284,6 +290,27 @@ const CreateStatusPage = () => { /> )} /> + ( + + )} + /> } /> @@ -504,6 +531,33 @@ const CreateStatusPage = () => { } /> )} + {showStep(1) && ( + ( + + )} + /> + } + /> + )} {showStep(1) && ( { window.open( - `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`, + getStatusPagePublicUrl(statusPage), "_blank", "noopener,noreferrer" ); diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 9c4001fae..7700f1c29 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -14,6 +15,12 @@ import { type StatusPageResponse, type StatusPageTheme, } from "@/Types/StatusPage"; +import { + buildStatusPageApiPath, + getStatusPagePreviewUrl, + getStatusPagePublicUrl, + isCustomDomainHost, +} from "@/Utils/statusPageUrl"; import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { @@ -33,6 +40,7 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero"; import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader"; import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero"; +import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles"; const THEME_CONFIGS: Record> = { refined: { @@ -58,6 +66,11 @@ const THEME_CONFIGS: Record> = { HeroSlot: EditorialHero, overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, }, + minimal: { + createStyles: minimalStyles, + HeaderSlot: RefinedHeader, + HeroSlot: RefinedHero, + }, }; const StatusPageView = () => { @@ -67,9 +80,14 @@ const StatusPageView = () => { const isAdmin = useIsAdmin(); const location = useLocation(); - const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const onCustomDomainHost = isCustomDomainHost(); + const isPublic = + onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); - const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; + const apiUrl = buildStatusPageApiPath({ + url, + useCustomDomain: onCustomDomainHost, + }); const { data, isLoading, error } = useGet( apiUrl, @@ -111,17 +129,9 @@ const StatusPageView = () => { ); } - const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; - const themedRenderer = ( - - ); - // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; return ( { timezone={statusPage.timezone} paintBody > - {themedRenderer} + ); } - const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + const publicUrl = getStatusPagePublicUrl(statusPage); + const previewUrl = getStatusPagePreviewUrl(statusPage); return ( { timezone={statusPage.timezone} transparent > - {themedRenderer} + + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx index b893aa85c..183e1fdea 100644 --- a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => { {url} - {children} + + {children} + ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index 9153ab994..c4df4e207 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -99,9 +99,9 @@ export const boldStyles = ( }, top: { - display: "flex", - alignItems: "center", + alignItems: { xs: "flex-start", md: "center" }, justifyContent: "space-between", + gap: "12px", mb: "36px", }, brand: { @@ -130,7 +130,7 @@ export const boldStyles = ( hero: { position: "relative", borderRadius: "22px", - padding: "36px 36px 32px", + padding: { xs: "24px 20px", md: "36px 36px 32px" }, mb: "28px", background: isDark ? heroBgDark : heroBgLight, border: `1px solid ${tokens.border}`, @@ -149,13 +149,14 @@ export const boldStyles = ( }, heroTitle: { m: 0, - fontSize: 34, + fontSize: { xs: 24, md: 34 }, fontWeight: 800, letterSpacing: "-0.02em", lineHeight: 1.1, color: tokens.text, display: "flex", - alignItems: "center", + alignItems: { xs: "flex-start", md: "center" }, + flexWrap: "wrap", gap: "10px", }, heroCheck: (tone) => ({ @@ -171,9 +172,15 @@ export const boldStyles = ( }), heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 }, - chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" }, + chartSwitchWrap: { + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + mb: "16px", + }, chartSwitch: { display: "inline-flex", + width: { xs: "100%", md: "auto" }, + maxWidth: "100%", border: `1px solid ${tokens.border}`, borderRadius: "999px", background: tokens.surface, @@ -181,16 +188,20 @@ export const boldStyles = ( gap: "2px", }, chartSwitchButton: (active) => ({ + flex: 1, + minWidth: 0, + whiteSpace: "nowrap", + textAlign: "center", border: 0, background: active ? tokens.upSoft : "transparent", fontFamily: "inherit", fontSize: 11, - padding: "8px 18px", + padding: { xs: "8px 6px", md: "8px 18px" }, cursor: "pointer", color: active ? tokens.up : tokens.textMuted, fontWeight: 700, textTransform: "uppercase", - letterSpacing: "0.12em", + letterSpacing: { xs: "0.04em", md: "0.12em" }, transition: "background 0.2s, color 0.2s", borderRadius: "999px", "&:hover": { color: active ? tokens.up : tokens.text }, @@ -217,13 +228,21 @@ export const boldStyles = ( }, cardRow: { display: "grid", - gridTemplateColumns: "1fr auto", - alignItems: "center", - gap: "24px", + alignItems: { xs: "start", md: "center" }, + gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, + gridTemplateAreas: { + xs: `"name" "status" "meta"`, + md: `"name name" "meta status"`, + }, + columnGap: { md: "24px" }, + rowGap: { xs: "10px", md: "6px" }, p: "22px 28px", }, - cardLeft: { minWidth: 0 }, + cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, monitorName: { + gridArea: "name", + minWidth: 0, + width: { xs: "100%", md: "auto" }, fontWeight: 700, fontSize: 17, letterSpacing: "-0.01em", @@ -233,10 +252,9 @@ export const boldStyles = ( whiteSpace: "nowrap", }, monitorMeta: { - display: "flex", + gridArea: "meta", gap: "10px", - alignItems: "center", - mt: "6px", + alignItems: { xs: "flex-start", md: "center" }, flexWrap: "wrap", }, pill: { @@ -273,6 +291,8 @@ export const boldStyles = ( }, badge: (tone) => ({ + gridArea: "status", + justifySelf: "start", fontSize: 11, fontWeight: 800, padding: "6px 14px", @@ -296,10 +316,10 @@ export const boldStyles = ( }), heatmap: { - padding: "0 28px 22px", + padding: { xs: "0 12px 18px", md: "0 28px 22px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, height: 48, }, heatmapCell: (kind) => ({ @@ -312,10 +332,10 @@ export const boldStyles = ( }), histogram: { - padding: "0 28px", + padding: { xs: "0 12px", md: "0 28px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, alignItems: "flex-end", height: 48, }, diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 57128180e..868ebf186 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -90,9 +90,9 @@ export const editorialStyles = ( }, top: { - display: "flex", - alignItems: "flex-end", + alignItems: { xs: "flex-start", md: "flex-end" }, justifyContent: "space-between", + gap: "12px", mb: "48px", borderBottom: `2px solid ${tokens.text}`, pb: "18px", @@ -119,7 +119,7 @@ export const editorialStyles = ( }, statusLine: { - fontSize: 22, + fontSize: { xs: 18, md: 22 }, fontWeight: 400, m: 0, mb: "8px", @@ -144,26 +144,32 @@ export const editorialStyles = ( letterSpacing: "0.12em", color: tokens.textMuted, m: 0, - mb: "56px", + mb: { xs: "32px", md: "56px" }, }, chartSwitchWrap: { display: "flex", - justifyContent: "flex-end", + justifyContent: { xs: "center", md: "flex-end" }, mb: "20px", fontFamily: EDITORIAL_SECONDARY_SANS_STACK, }, chartSwitch: { display: "inline-flex", + width: { xs: "100%", md: "auto" }, + maxWidth: "100%", border: `1px solid ${tokens.text}`, "& > button + button": { borderLeft: `1px solid ${tokens.text}` }, }, chartSwitchButton: (active) => ({ + flex: 1, + minWidth: 0, + whiteSpace: "nowrap", + textAlign: "center", fontFamily: "inherit", fontSize: 10, textTransform: "uppercase", - letterSpacing: "0.2em", - padding: "6px 14px", + letterSpacing: { xs: "0.05em", md: "0.2em" }, + padding: { xs: "6px 6px", md: "6px 14px" }, border: 0, background: active ? tokens.text : "transparent", color: active ? tokens.bg : tokens.text, @@ -188,12 +194,20 @@ export const editorialStyles = ( }, cardRow: { display: "grid", - gridTemplateColumns: "1fr auto", - alignItems: "baseline", - gap: "20px", + alignItems: { xs: "start", md: "baseline" }, + gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, + gridTemplateAreas: { + xs: `"name" "status" "meta"`, + md: `"name name" "meta status"`, + }, + columnGap: { md: "20px" }, + rowGap: { xs: "10px", md: "6px" }, }, - cardLeft: { minWidth: 0 }, + cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, monitorName: { + gridArea: "name", + minWidth: 0, + width: { xs: "100%", md: "auto" }, fontSize: 20, fontWeight: 700, letterSpacing: "-0.015em", @@ -203,14 +217,13 @@ export const editorialStyles = ( whiteSpace: "nowrap", }, monitorMeta: { + gridArea: "meta", fontFamily: EDITORIAL_SECONDARY_SANS_STACK, fontSize: 12, color: tokens.textMuted, - mt: "6px", - display: "flex", gap: "12px", flexWrap: "wrap", - alignItems: "center", + alignItems: { xs: "flex-start", md: "center" }, }, pill: { textTransform: "uppercase", @@ -234,6 +247,8 @@ export const editorialStyles = ( }, badge: (tone) => ({ + gridArea: "status", + justifySelf: "start", fontFamily: EDITORIAL_SECONDARY_SANS_STACK, fontSize: 10, fontWeight: 700, @@ -250,7 +265,7 @@ export const editorialStyles = ( mt: "16px", display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "2px", + gap: { xs: "1px", md: "2px" }, height: 40, }, heatmapCell: (kind) => ({ @@ -262,7 +277,7 @@ export const editorialStyles = ( mt: "16px", display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "2px", + gap: { xs: "1px", md: "2px" }, alignItems: "flex-end", height: 40, }, diff --git a/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts new file mode 100644 index 000000000..27370c0ad --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts @@ -0,0 +1,66 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import type { OverallTone } from "../shared/overallStatus"; +import { refinedStyles, type RefinedStyles } from "../refined/styles"; + +type FlatSx = Exclude, readonly unknown[] | ((theme: Theme) => unknown)>; + +const extend = (base: SxProps, overrides: SxProps): SxProps => [ + base as FlatSx, + overrides as FlatSx, +]; + +export const minimalStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): RefinedStyles => { + const base = refinedStyles(tokens, isDark); + + return { + ...base, + page: extend(base.page, { + maxWidth: 820, + p: { xs: "32px 16px 64px", md: "48px 20px 72px" }, + }), + top: extend(base.top, { + mb: "44px", + pb: "16px", + borderBottom: `1px solid ${tokens.border}`, + }), + logoMono: extend(base.logoMono, { + borderRadius: "50%", + background: tokens.text, + }), + hero: extend(base.hero, { + alignItems: "center", + background: "transparent", + border: 0, + boxShadow: "none", + p: 0, + mb: "40px", + }), + heroRow: extend(base.heroRow, { justifyContent: "center" }), + heroTitle: extend(base.heroTitle, { + flex: "initial", + fontSize: { xs: 22, md: 28 }, + fontWeight: 650, + textAlign: "center", + }), + heroIcon: (_tone: OverallTone) => ({ display: "none" }), + heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }), + chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }), + card: extend(base.card, { + boxShadow: "none", + transition: "none", + "&:hover": { transform: "none", boxShadow: "none" }, + }), + cardRow: extend(base.cardRow, { p: "14px 16px" }), + badge: (tone) => + extend(base.badge(tone), { + background: "transparent", + p: 0, + }), + gauge: extend(base.gauge, { borderRadius: tokens.radius }), + footer: extend(base.footer, { mt: "48px", fontSize: 11 }), + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx index a7cf93bc7..68c2b5903 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx @@ -11,24 +11,24 @@ export const ModernHero = ({ const { t } = useTranslation(); return ( - - + + {overall.message} - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitorCount, - })} - + {overall.icon} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} - {overall.icon} ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index 1ee3af741..2f730ee63 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -17,7 +17,7 @@ export interface ModernStyles { logoImg: SxProps; hero: SxProps; pulse: (tone: OverallTone) => SxProps; - statusCopy: SxProps; + heroRow: SxProps; heroTitle: SxProps; heroSub: SxProps; heroIcon: (tone: OverallTone) => SxProps; @@ -117,9 +117,9 @@ export const modernStyles = ( }, top: { - display: "flex", - alignItems: "center", + alignItems: { xs: "flex-start", md: "center" }, justifyContent: "space-between", + gap: "12px", mb: "32px", }, brand: { @@ -148,11 +148,11 @@ export const modernStyles = ( hero: { position: "relative", borderRadius: "20px", - padding: "28px 32px", + padding: { xs: "20px", md: "28px 32px" }, mb: "24px", display: "flex", - alignItems: "center", - gap: "20px", + flexDirection: "column", + gap: "4px", background: tokens.surface, border: `1px solid ${tokens.border}`, boxShadow: cardShadow, @@ -182,25 +182,37 @@ export const modernStyles = ( }, }; }, - statusCopy: { flex: 1, minWidth: 0 }, + heroRow: { + display: "flex", + alignItems: "center", + gap: { xs: "14px", md: "20px" }, + }, heroTitle: { m: 0, - mb: "4px", - fontSize: 22, + flex: 1, + minWidth: 0, + fontSize: { xs: 18, md: 22 }, fontWeight: 700, letterSpacing: "-0.02em", color: tokens.text, }, heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, heroIcon: (tone) => ({ + display: { xs: "none", md: "flex" }, color: toneColor(tone, tokens), - display: "flex", alignItems: "center", + flexShrink: 0, }), - chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" }, + chartSwitchWrap: { + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + mb: "14px", + }, chartSwitch: { display: "inline-flex", + width: { xs: "100%", md: "auto" }, + maxWidth: "100%", border: `1px solid ${tokens.border}`, borderRadius: "999px", background: tokens.surface, @@ -208,16 +220,20 @@ export const modernStyles = ( gap: "2px", }, chartSwitchButton: (active) => ({ + flex: 1, + minWidth: 0, + whiteSpace: "nowrap", + textAlign: "center", border: 0, background: active ? tokens.upSoft : "transparent", fontFamily: "inherit", fontSize: 11, - padding: "6px 16px", + padding: { xs: "6px 6px", md: "6px 16px" }, cursor: "pointer", color: active ? tokens.up : tokens.textMuted, fontWeight: 600, textTransform: "uppercase", - letterSpacing: "0.08em", + letterSpacing: { xs: "0.04em", md: "0.08em" }, transition: "background 0.2s, color 0.2s", borderRadius: "999px", "&:hover": { color: active ? tokens.up : tokens.text }, @@ -256,13 +272,21 @@ export const modernStyles = ( }, cardRow: { display: "grid", - gridTemplateColumns: "1fr auto", - alignItems: "center", - gap: "20px", + alignItems: { xs: "start", md: "center" }, + gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, + gridTemplateAreas: { + xs: `"name" "status" "meta"`, + md: `"name name" "meta status"`, + }, + columnGap: { md: "20px" }, + rowGap: { xs: "10px", md: "6px" }, p: "18px 24px", }, - cardLeft: { minWidth: 0 }, + cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, monitorName: { + gridArea: "name", + minWidth: 0, + width: { xs: "100%", md: "auto" }, fontWeight: 600, fontSize: 15, letterSpacing: "-0.005em", @@ -272,10 +296,9 @@ export const modernStyles = ( whiteSpace: "nowrap", }, monitorMeta: { - display: "flex", + gridArea: "meta", gap: "10px", - alignItems: "center", - mt: "6px", + alignItems: { xs: "flex-start", md: "center" }, flexWrap: "wrap", }, pill: { @@ -299,6 +322,8 @@ export const modernStyles = ( }, badge: (tone) => ({ + gridArea: "status", + justifySelf: "start", fontSize: 11, fontWeight: 700, padding: "5px 12px", @@ -319,10 +344,10 @@ export const modernStyles = ( }), heatmap: { - padding: "0 24px 20px", + padding: { xs: "0 12px 16px", md: "0 24px 20px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, height: 46, }, heatmapCell: (kind) => ({ @@ -334,10 +359,10 @@ export const modernStyles = ( }), histogram: { - padding: "0 24px", + padding: { xs: "0 12px", md: "0 24px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, alignItems: "flex-end", height: 46, }, diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx index c9a59ad77..b46fdf263 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx @@ -11,24 +11,24 @@ export const RefinedHero = ({ const { t } = useTranslation(); return ( - - + + {overall.message} - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitorCount, - })} - + {overall.icon} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} - {overall.icon} ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index a6a52c021..0cf754af6 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -17,7 +17,7 @@ export interface RefinedStyles { company: SxProps; hero: SxProps; statusDot: (tone: OverallTone) => SxProps; - statusCopy: SxProps; + heroRow: SxProps; heroTitle: SxProps; heroSub: SxProps; heroIcon: (tone: OverallTone) => SxProps; @@ -102,9 +102,9 @@ export const refinedStyles = ( }, top: { - display: "flex", - alignItems: "center", + alignItems: { xs: "flex-start", md: "center" }, justifyContent: "space-between", + gap: "12px", mb: "28px", }, brand: { @@ -133,10 +133,10 @@ export const refinedStyles = ( background: tokens.surface, border: `1px solid ${tokens.border}`, borderRadius: tokens.radius, - padding: "22px 24px", + padding: { xs: "18px 20px", md: "22px 24px" }, display: "flex", - alignItems: "center", - gap: "16px", + flexDirection: "column", + gap: "4px", boxShadow: cardShadow, mb: "20px", }, @@ -148,10 +148,15 @@ export const refinedStyles = ( boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`, flexShrink: 0, }), - statusCopy: { flex: 1, minWidth: 0 }, + heroRow: { + display: "flex", + alignItems: "center", + gap: { xs: "12px", md: "16px" }, + }, heroTitle: { m: 0, - mb: "2px", + flex: 1, + minWidth: 0, fontSize: 17, fontWeight: 600, letterSpacing: "-0.01em", @@ -159,14 +164,21 @@ export const refinedStyles = ( }, heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, heroIcon: (tone) => ({ + display: { xs: "none", md: "flex" }, color: toneColor(tone, tokens), - display: "flex", alignItems: "center", + flexShrink: 0, }), - chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" }, + chartSwitchWrap: { + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + mb: "12px", + }, chartSwitch: { display: "inline-flex", + width: { xs: "100%", md: "auto" }, + maxWidth: "100%", border: `1px solid ${tokens.border}`, borderRadius: "8px", background: tokens.surface, @@ -174,11 +186,15 @@ export const refinedStyles = ( gap: "2px", }, chartSwitchButton: (active) => ({ + flex: 1, + minWidth: 0, + whiteSpace: "nowrap", + textAlign: "center", border: 0, background: active ? tokens.upSoft : "transparent", fontFamily: "inherit", fontSize: 11, - padding: "5px 14px", + padding: { xs: "5px 6px", md: "5px 14px" }, cursor: "pointer", color: active ? tokens.up : tokens.textMuted, borderRadius: "5px", @@ -207,13 +223,21 @@ export const refinedStyles = ( }, cardRow: { display: "grid", - gridTemplateColumns: "1fr auto", - alignItems: "center", - gap: "16px", + alignItems: { xs: "start", md: "center" }, + gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, + gridTemplateAreas: { + xs: `"name" "status" "meta"`, + md: `"name name" "meta status"`, + }, + columnGap: { md: "16px" }, + rowGap: { xs: "10px", md: "4px" }, p: "16px 20px", }, - cardLeft: { minWidth: 0 }, + cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, monitorName: { + gridArea: "name", + minWidth: 0, + width: { xs: "100%", md: "auto" }, fontWeight: 600, fontSize: 14, letterSpacing: "-0.005em", @@ -223,10 +247,9 @@ export const refinedStyles = ( whiteSpace: "nowrap", }, monitorMeta: { - display: "flex", + gridArea: "meta", gap: "10px", - alignItems: "center", - mt: "4px", + alignItems: { xs: "flex-start", md: "center" }, flexWrap: "wrap", }, pill: { @@ -251,6 +274,8 @@ export const refinedStyles = ( }, badge: (tone) => ({ + gridArea: "status", + justifySelf: "start", fontSize: 11, fontWeight: 600, padding: "4px 10px", @@ -261,10 +286,10 @@ export const refinedStyles = ( }), heatmap: { - padding: "0 20px 16px", + padding: { xs: "0 10px 14px", md: "0 20px 16px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, height: 42, }, heatmapCell: (kind) => ({ @@ -276,10 +301,10 @@ export const refinedStyles = ( }), histogram: { - padding: "0 20px", + padding: { xs: "0 10px", md: "0 20px" }, display: "grid", gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, - gap: "3px", + gap: { xs: "1px", md: "3px" }, alignItems: "flex-end", height: 42, }, diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 71aa85a89..1dfc0bd1b 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -27,6 +27,7 @@ import { statusBadgeKey, } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { formatPercentage } from "@/Utils/FormatUtils"; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; @@ -101,8 +102,10 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { return ( - {statusPage.customCSS}} + { monitorCount={monitors.length} styles={styles} /> - + { const showInfra = isHardware && statusPage.showInfrastructure !== false; const showChart = !isHardware && statusPage.showCharts !== false; const badgeTone = monitorBadgeTone(monitor.status); + const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0); return ( { sx={styles.card} > - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - + {monitor.name} {t(statusBadgeKey[monitor.status])} + + + {getMonitorTypeLabel(monitor.type, t)} + + + {uptimePercentage} + + {monitor.url && ( + + {monitor.url} + + )} + {showInfra && ( diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts index f84b6f100..fdb251c07 100644 --- a/client/src/Pages/StatusPage/Status/themes/tokens.ts +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -191,9 +191,47 @@ const editorial: ThemeVariants = { }, }; +const minimal: ThemeVariants = { + light: { + bg: "#ffffff", + surface: "#ffffff", + border: "#dfe3e8", + text: "#17191c", + textMuted: "#667085", + up: "#12a174", + upStrong: "#087a57", + upSoft: "#e7f7f1", + degraded: "#b54708", + degradedSoft: "#fff1dc", + down: "#c4323f", + downSoft: "#fde8ea", + warn: "#c27803", + warnSoft: "#fff3d6", + radius: "6px", + }, + dark: { + bg: "#0d0f12", + surface: "#111419", + border: "#2b3038", + text: "#f2f4f7", + textMuted: "#98a2b3", + up: "#32d6a0", + upStrong: "#1eb783", + upSoft: "rgba(50,214,160,0.14)", + degraded: "#f0a44b", + degradedSoft: "rgba(240,164,75,0.16)", + down: "#ff6675", + downSoft: "rgba(255,102,117,0.16)", + warn: "#f2b84b", + warnSoft: "rgba(242,184,75,0.16)", + radius: "6px", + }, +}; + export const themeTokens: Record = { refined, modern, bold, editorial, + minimal, }; diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index fccf6105b..d2d9af8b5 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl"; + +import { type StatusPage } from "@/Types/StatusPage"; interface StatusPagesTableProps { data: StatusPage[]; @@ -53,8 +55,7 @@ export const StatusPagesTable = ({ const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { if (row.isPublished) { e.stopPropagation(); - const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`; - window.open(url, "_blank", "noopener,noreferrer"); + window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer"); } }; @@ -70,7 +71,9 @@ export const StatusPagesTable = ({ content: t("pages.statusPages.table.headers.url"), render: (row) => { const content = row.isPublished - ? `/${row.url}` + ? row.customDomain + ? getStatusPagePublicPath(row) + : `/${row.url}` : t("pages.statusPages.table.unpublished"); return ( { + const uptimePercentage = row.uptimePercentage ?? 0; + const formattedPercentage = formatPercentage(uptimePercentage); + const paletteKey = getUptimePercentageColor(uptimePercentage); + const color = theme.palette[paletteKey].main; + return ( + + + + ); + }, + }, { id: "tags", content: t("common.table.headers.tags"), @@ -304,7 +324,9 @@ export const MonitorTable = ({ ), render: (row) => { - return row.type; + return row.type === "http" && row.method + ? `${row.type} (${row.method})` + : row.type; }, }, { diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index c3091a924..e73b38255 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs"; import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; import CreateMonitor from "@/Pages/CreateMonitor"; +import { isCustomDomainHost } from "@/Utils/statusPageUrl"; const Routes = () => { + if (isCustomDomainHost()) { + return ( + + } + /> + } + /> + + ); + } + return ( { } }; +export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => { + if (uptimePercentage >= 0.75) { + return "success"; + } else if (uptimePercentage >= 0.5) { + return "warning"; + } else { + return "error"; + } +}; + export const getInfraGaugeColor = (val: number, theme: any) => { if (val < 50) { return theme.palette.success.main; diff --git a/client/src/Utils/customCss.ts b/client/src/Utils/customCss.ts new file mode 100644 index 000000000..02a267daa --- /dev/null +++ b/client/src/Utils/customCss.ts @@ -0,0 +1,16 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects +// the same CSS the API would. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some( + (token) => + (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value) + ); +}; diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts index 33a5f3de1..362a7f3b7 100644 --- a/client/src/Utils/statusCode.ts +++ b/client/src/Utils/statusCode.ts @@ -74,3 +74,96 @@ export const getStatusCodeValueType = (code: number): ValueType => { if (code < 400) return "neutral"; return "negative"; }; + +export interface HttpStatusCodeOption { + id: number; + name: string; +} + +/** + * Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts + */ +export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [ + { id: 100, name: "100 - Continue" }, + { id: 101, name: "101 - Switching Protocols" }, + { id: 102, name: "102 - Processing" }, + { id: 103, name: "103 - Early Hints" }, + { id: 200, name: "200 - OK" }, + { id: 201, name: "201 - Created" }, + { id: 202, name: "202 - Accepted" }, + { id: 203, name: "203 - Non-Authoritative Information" }, + { id: 204, name: "204 - No Content" }, + { id: 205, name: "205 - Reset Content" }, + { id: 206, name: "206 - Partial Content" }, + { id: 207, name: "207 - Multi-Status" }, + { id: 208, name: "208 - Already Reported" }, + { id: 226, name: "226 - IM Used" }, + { id: 300, name: "300 - Multiple Choices" }, + { id: 301, name: "301 - Moved Permanently" }, + { id: 302, name: "302 - Found" }, + { id: 303, name: "303 - See Other" }, + { id: 304, name: "304 - Not Modified" }, + { id: 305, name: "305 - Use Proxy" }, + { id: 307, name: "307 - Temporary Redirect" }, + { id: 308, name: "308 - Permanent Redirect" }, + { id: 400, name: "400 - Bad Request" }, + { id: 401, name: "401 - Unauthorized" }, + { id: 402, name: "402 - Payment Required" }, + { id: 403, name: "403 - Forbidden" }, + { id: 404, name: "404 - Not Found" }, + { id: 405, name: "405 - Method Not Allowed" }, + { id: 406, name: "406 - Not Acceptable" }, + { id: 407, name: "407 - Proxy Authentication Required" }, + { id: 408, name: "408 - Request Timeout" }, + { id: 409, name: "409 - Conflict" }, + { id: 410, name: "410 - Gone" }, + { id: 411, name: "411 - Length Required" }, + { id: 412, name: "412 - Precondition Failed" }, + { id: 413, name: "413 - Payload Too Large" }, + { id: 414, name: "414 - URI Too Long" }, + { id: 415, name: "415 - Unsupported Media Type" }, + { id: 416, name: "416 - Range Not Satisfiable" }, + { id: 417, name: "417 - Expectation Failed" }, + { id: 418, name: "418 - I'm a Teapot" }, + { id: 419, name: "419 - Page Expired (Laravel)" }, + { id: 420, name: "420 - Enhance Your Calm (Twitter)" }, + { id: 421, name: "421 - Misdirected Request" }, + { id: 422, name: "422 - Unprocessable Entity" }, + { id: 423, name: "423 - Locked" }, + { id: 424, name: "424 - Failed Dependency" }, + { id: 425, name: "425 - Too Early" }, + { id: 426, name: "426 - Upgrade Required" }, + { id: 428, name: "428 - Precondition Required" }, + { id: 429, name: "429 - Too Many Requests" }, + { id: 431, name: "431 - Request Header Fields Too Large" }, + { id: 440, name: "440 - Login Time-out (IIS)" }, + { id: 449, name: "449 - Retry With (IIS)" }, + { id: 451, name: "451 - Unavailable For Legal Reasons" }, + { id: 460, name: "460 - Client Closed Connection (AWS ELB)" }, + { id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" }, + { id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" }, + { id: 499, name: "499 - Client Closed Request (NGINX)" }, + { id: 500, name: "500 - Internal Server Error" }, + { id: 501, name: "501 - Not Implemented" }, + { id: 502, name: "502 - Bad Gateway" }, + { id: 503, name: "503 - Service Unavailable" }, + { id: 504, name: "504 - Gateway Timeout" }, + { id: 505, name: "505 - HTTP Version Not Supported" }, + { id: 506, name: "506 - Variant Also Negotiates" }, + { id: 507, name: "507 - Insufficient Storage" }, + { id: 508, name: "508 - Loop Detected" }, + { id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" }, + { id: 510, name: "510 - Not Extended" }, + { id: 511, name: "511 - Network Authentication Required" }, + { id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" }, + { id: 521, name: "521 - Web Server Is Down (Cloudflare)" }, + { id: 522, name: "522 - Connection Timed Out (Cloudflare)" }, + { id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" }, + { id: 524, name: "524 - A Timeout Occurred (Cloudflare)" }, + { id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" }, + { id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" }, + { id: 527, name: "527 - Railgun Error (Cloudflare)" }, + { id: 529, name: "529 - Site is overloaded" }, + { id: 530, name: "530 - Site is frozen (Cloudflare)" }, + { id: 561, name: "561 - Unauthorized (AWS ELB)" }, +]; diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts new file mode 100644 index 000000000..d5628d6bc --- /dev/null +++ b/client/src/Utils/statusPageUrl.ts @@ -0,0 +1,99 @@ +import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; + +const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined; + +const getClientOriginProtocol = (): string => { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return "https:"; + } + + try { + return new URL(source).protocol; + } catch { + return "https:"; + } +}; + +const buildCustomDomainOrigin = (customDomain: string): string => + `${getClientOriginProtocol()}//${customDomain}`; + +export const getAppHostname = (): string | null => { + const source = + CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined); + if (!source) { + return null; + } + + try { + return new URL(source).hostname.toLowerCase(); + } catch { + return null; + } +}; + +export const isCustomDomainHost = (): boolean => { + if (typeof window === "undefined") { + return false; + } + + const appHostname = getAppHostname(); + if (!appHostname) { + return false; + } + + return window.location.hostname.toLowerCase() !== appHostname; +}; + +export const getStatusPagePublicPath = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const getStatusPagePublicUrl = ( + statusPage: Pick +): string => { + if (statusPage.customDomain) { + return buildCustomDomainOrigin(statusPage.customDomain); + } + + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +// Same-origin URL for the admin preview iframe; a custom domain would be cross +// origin and blocked by the admin document's CSP frame-src. +export const getStatusPagePreviewUrl = (statusPage: Pick): string => { + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + +export const buildStatusPageApiPath = (options: { + url?: string; + useCustomDomain?: boolean; +}): string | null => { + const query = "type=uptime&type=infrastructure"; + + if (options.useCustomDomain && typeof window !== "undefined") { + const domain = encodeURIComponent(window.location.hostname); + return `/status-page/resolve?domain=${domain}&${query}`; + } + + if (options.url) { + return `/status-page/${options.url}?${query}`; + } + + return null; +}; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 80d0d77c4..a31cdb919 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,7 +1,13 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { GeoContinents } from "@/Types/GeoCheck"; -import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor"; +import { + DnsRecordTypes, + HttpMethods, + PageSpeedStrategies, + type MonitorType, +} from "@/Types/Monitor"; +import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -53,9 +59,15 @@ const geoCheckFields = { }; // HTTP monitor schema +const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id)); +const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), { + message: "Must be a valid HTTP status code", +}); + const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, + method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), matchMethod: z @@ -64,6 +76,10 @@ const httpSchema = baseSchema.extend({ .register(monitorStepRegistry, { step: 2 }), expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }), jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }), + customUpCodes: z + .array(httpStatusCode) + .optional() + .register(monitorStepRegistry, { step: 2 }), ...geoCheckFields, }); diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 542f6b2ae..eddde8ab7 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; +import { cssReferencesExternalResource } from "@/Utils/customCss"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -9,6 +10,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage" // field definitions. export const statusPageStepRegistry = z.registry<{ step: number }>(); +const statusPageHostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +const normalizeCustomDomainInput = (raw: string | null): string | null => { + if (raw === null) { + return null; + } + + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + return withoutScheme.split("/")[0]?.split(":")[0] ?? null; +}; + export const statusPageSchema = z.object({ companyName: z .string() @@ -22,6 +40,12 @@ export const statusPageSchema = z.object({ /^[a-z0-9-]+$/, "URL can only contain lowercase letters, numbers, and hyphens" ), + customDomain: z + .union([z.string(), z.null()]) + .transform(normalizeCustomDomainInput) + .refine((domain) => domain === null || statusPageHostnameRegex.test(domain), { + message: "Enter a valid domain name (e.g. status.example.com)", + }), timezone: z.string().optional(), type: z .array(z.enum(["uptime", "infrastructure"])) @@ -35,7 +59,14 @@ export const statusPageSchema = z.object({ showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), - customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional() + .register(statusPageStepRegistry, { step: 1 }), theme: z .enum(STATUS_PAGE_THEMES) .optional() diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 504fa5b49..6e315bd9a 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -941,10 +941,7 @@ "monitorId": "معرّف المراقب", "runCount": "عدد التشغيلات", "failCount": "عدد حالات الفشل", - "lastRunAt": "آخر تشغيل في", - "lockedAt": "مقفل في", "lastFinishedAt": "آخر انتهاء في", - "lastRunTook": "مدة آخر تشغيل", "lastFailedAt": "آخر فشل في", "failReason": "سبب الفشل", "state": "الحالة" diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 9760e7157..3c1a29613 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -945,10 +945,7 @@ "monitorId": "ID del monitor", "runCount": "Recompte d'execucions", "failCount": "Recompte", - "lastRunAt": "Darrera execució", - "lockedAt": "Bloquejat el", "lastFinishedAt": "Darrera finalització", - "lastRunTook": "Durada de la darrera execució", "lastFailedAt": "Darrera fallada", "failReason": "Motiu de la fallada", "state": "Estat" diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 9f7ba63d5..ae683aefe 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -941,10 +941,7 @@ "monitorId": "ID monitoru", "runCount": "Počet spuštění", "failCount": "Počet selhání", - "lastRunAt": "Poslední spuštění", - "lockedAt": "Uzamčeno v", "lastFinishedAt": "Naposledy dokončeno", - "lastRunTook": "Poslední běh trval", "lastFailedAt": "Poslední selhání", "failReason": "Důvod selhání", "state": "Stav" diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 2c6a33ae4..e042f9d55 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -941,10 +941,7 @@ "monitorId": "Monitor-ID", "runCount": "Ausführungen", "failCount": "Fehlerzahl", - "lastRunAt": "Letzter Lauf am", - "lockedAt": "Gesperrt am", "lastFinishedAt": "Zuletzt abgeschlossen am", - "lastRunTook": "Letzter Lauf dauerte", "lastFailedAt": "Letzter Fehler am", "failReason": "Fehlergrund", "state": "Status" diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 0b78ba1e9..c320c5767 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -490,6 +490,13 @@ "advanced": { "description": "Optional settings for advanced use cases", "option": { + "method": { + "label": "Request method", + "description": { + "GET": "GET fetches the full response including the body. Advanced matching fully supported", + "HEAD": "HEAD fetches only response headers for reducing load on the target. Supports status code matching but not advanced matching as there is no body." + } + }, "advancedMatching": { "label": "Use advanced matching" }, @@ -505,6 +512,11 @@ "equal": "Equal", "include": "Include", "regex": "Regex" + }, + "customUpCodes": { + "label": "Accepted status codes", + "description": "Add any non-2xx codes that should also be considered successful.", + "removeAriaLabel": "Remove status code" } }, "title": "Advanced settings" @@ -975,6 +987,11 @@ }, "mongoDBStats": { "readyState": "Ready state", + "readsPerSecond": "Reads/s", + "insertsPerSecond": "Inserts/s", + "updatesPerSecond": "Updates/s", + "deletesPerSecond": "Deletes/s", + "writesPerSecond": "Total writes/s", "host": "Host", "port": "Port", "dbName": "Database name", @@ -1015,11 +1032,9 @@ "monitorId": "Monitor ID", "runCount": "Run count", "failCount": "Count", - "lastRunAt": "Last run at", - "lockedAt": "Locked at", + "nextScheduledAt": "Next run at", "lockedBy": "Locked by", "lastFinishedAt": "Last finished at", - "lastRunTook": "Last run took", "lastFailedAt": "Last failed", "failReason": "Fail reason" } @@ -1509,6 +1524,11 @@ "url": { "label": "Your status page address", "placeholder": "my-status-page" + }, + "customDomain": { + "label": "Custom domain", + "placeholder": "status.example.com", + "helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain." } } }, @@ -1565,6 +1585,10 @@ "editorial": { "name": "Editorial", "description": "Serif headings and hairline dividers. Distinctive and calm." + }, + "minimal": { + "name": "Minimal", + "description": "Flat, focused, and compact with only the essentials." } } }, @@ -1574,6 +1598,16 @@ "light": "Light only", "dark": "Dark only" }, + "customCSS": { + "title": "Custom CSS", + "description": "Add custom CSS to fine-tune the look of your public status page.", + "option": { + "customCSS": { + "label": "Custom CSS", + "placeholder": "footer { display: none; }" + } + } + }, "features": { "title": "Features", "description": "Configure what information is displayed on your status page.", @@ -1596,6 +1630,9 @@ "header": { "title": "Status pages", "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." + }, + "preview": { + "title": "Status page preview" } }, "uptime": { @@ -1607,7 +1644,8 @@ }, "table": { "headers": { - "responseTime": "Response time" + "responseTime": "Response time", + "uptime": "Uptime %" } }, "fallback": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 70dd557d0..1c45ecad4 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -941,10 +941,7 @@ "monitorId": "ID de monitor", "runCount": "Conteo de ejecuciones", "failCount": "Conteo de fallas", - "lastRunAt": "Última ejecución", - "lockedAt": "Bloqueado a las", "lastFinishedAt": "Última finalización", - "lastRunTook": "Última ejecución tomó", "lastFailedAt": "Última falla", "failReason": "Razón de falla", "state": "Estado" diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 3c34577a2..3ca1de82a 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -941,10 +941,7 @@ "monitorId": "Monitorin tunnus", "runCount": "Suorituskerrat", "failCount": "Epäonnistumiskerrat", - "lastRunAt": "Viimeisin suoritus", - "lockedAt": "Lukittu", "lastFinishedAt": "Viimeisin valmistuminen", - "lastRunTook": "Viimeisin suoritus kesti", "lastFailedAt": "Viimeisin epäonnistuminen", "failReason": "Epäonnistumisen syy", "state": "Tila" diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 49e7e94bc..9b3e983a6 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -941,10 +941,7 @@ "monitorId": "ID du moniteur", "runCount": "Nombre d'exécutions", "failCount": "Nombre d'échecs", - "lastRunAt": "Dernière exécution", - "lockedAt": "Verrouillé le", "lastFinishedAt": "Dernière fin", - "lastRunTook": "Durée dernière exécution", "lastFailedAt": "Dernier échec", "failReason": "Raison de l'échec", "state": "État" diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index eb97dc105..031350b20 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -941,10 +941,7 @@ "monitorId": "モニターID", "runCount": "実行回数", "failCount": "失敗回数", - "lastRunAt": "最終実行日時", - "lockedAt": "ロック日時", "lastFinishedAt": "最終完了日時", - "lastRunTook": "最終実行時間", "lastFailedAt": "最終失敗日時", "failReason": "失敗理由", "state": "状態" diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 39cbf4577..0286f2857 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -941,10 +941,7 @@ "monitorId": "ID do monitor", "runCount": "Contagem de execuções", "failCount": "Contagem de falhas", - "lastRunAt": "Última execução em", - "lockedAt": "Bloqueado em", "lastFinishedAt": "Última conclusão em", - "lastRunTook": "Última execução durou", "lastFailedAt": "Última falha em", "failReason": "Motivo da falha", "state": "Estado" diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 9bfd20481..50226037a 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -941,10 +941,7 @@ "monitorId": "ID монитора", "runCount": "Кол-во запусков", "failCount": "Кол-во ошибок", - "lastRunAt": "Последний запуск", - "lockedAt": "Заблокировано в", "lastFinishedAt": "Последнее завершение", - "lastRunTook": "Последний запуск занял", "lastFailedAt": "Последняя ошибка", "failReason": "Причина ошибки", "state": "Состояние" diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 5873b65d4..c0889a72f 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -941,10 +941,7 @@ "monitorId": "รหัสมอนิเตอร์", "runCount": "จำนวนรัน", "failCount": "จำนวนล้มเหลว", - "lastRunAt": "รันล่าสุดเมื่อ", - "lockedAt": "ล็อกเมื่อ", "lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ", - "lastRunTook": "รันล่าสุดใช้เวลา", "lastFailedAt": "ล้มเหลวล่าสุดเมื่อ", "failReason": "สาเหตุที่ล้มเหลว", "state": "สถานะ" diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 2d6deaa97..c82091a83 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -941,10 +941,7 @@ "monitorId": "Monitör ID", "runCount": "Çalışma sayısı", "failCount": "Başarısızlık sayısı", - "lastRunAt": "Son çalışma zamanı", - "lockedAt": "Kilitlenme zamanı", "lastFinishedAt": "Son tamamlanma zamanı", - "lastRunTook": "Son çalışma süresi", "lastFailedAt": "Son başarısızlık zamanı", "failReason": "Başarısızlık nedeni", "state": "Durum" diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index de40bbf05..6c3b47b66 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -941,10 +941,7 @@ "monitorId": "ID монітора", "runCount": "Кількість запусків", "failCount": "Кількість помилок", - "lastRunAt": "Останній запуск", - "lockedAt": "Заблоковано о", "lastFinishedAt": "Останнє завершення", - "lastRunTook": "Останній запуск тривав", "lastFailedAt": "Остання помилка", "failReason": "Причина помилки", "state": "Стан" diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 13907f21b..72c933601 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -941,10 +941,7 @@ "monitorId": "ID trình giám sát", "runCount": "Số lần chạy", "failCount": "Số lần lỗi", - "lastRunAt": "Chạy lần cuối lúc", - "lockedAt": "Khóa lúc", "lastFinishedAt": "Hoàn thành lần cuối lúc", - "lastRunTook": "Lần chạy cuối mất", "lastFailedAt": "Lỗi lần cuối lúc", "failReason": "Lý do lỗi", "state": "Trạng thái" diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index a7166e6c2..4499dd5fb 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -941,10 +941,7 @@ "monitorId": "监控 ID", "runCount": "运行次数", "failCount": "失败次数", - "lastRunAt": "上次运行于", - "lockedAt": "锁定于", "lastFinishedAt": "上次完成于", - "lastRunTook": "上次运行耗时", "lastFailedAt": "上次失败于", "failReason": "失败原因", "state": "状态" diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ba599bb4f..7b560ae7d 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -941,10 +941,7 @@ "monitorId": "監控 ID", "runCount": "執行次數", "failCount": "失敗次數", - "lastRunAt": "最後執行於", - "lockedAt": "鎖定於", "lastFinishedAt": "最後完成於", - "lastRunTook": "最後執行耗時", "lastFailedAt": "最後失敗於", "failReason": "失敗原因", "state": "狀態" diff --git a/docker/.gitignore b/docker/.gitignore index 76567d514..4c3f83657 100755 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -2,14 +2,10 @@ !quickstart.sh !build_images.sh dev/mongo/data/* -dev/redis/data/* dist/mongo/data/* -dist/redis/data/* prod/mongo/data/* -prod/redis/data/* dist/docker-compose-test.yaml /dist-mono/mongo/data/* -/dist-mono/redis/data/* /dist-arm/mongo/data/* *.env diff --git a/docker/coolify/build_images.bat b/docker/coolify/build_images.bat index 60fc9aa4a..b940039b6 100644 --- a/docker/coolify/build_images.bat +++ b/docker/coolify/build_images.bat @@ -12,14 +12,11 @@ set dockerfiles[0]=.\docker\coolify\client.Dockerfile set services[1]=bluewaveuptime/uptime_database_mongo set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile -set services[2]=bluewaveuptime/uptime_redis -set dockerfiles[2]=.\docker\coolify\redis.Dockerfile - -set services[3]=bluewaveuptime/uptime_server -set dockerfiles[3]=.\docker\coolify\server.Dockerfile +set services[2]=bluewaveuptime/uptime_server +set dockerfiles[2]=.\docker\coolify\server.Dockerfile REM Loop through each service and build the corresponding image -for /L %%i in (0,1,2,3) do ( +for /L %%i in (0,1,2) do ( set service=!services[%%i]! set dockerfile=!dockerfiles[%%i]! diff --git a/docker/coolify/build_images.sh b/docker/coolify/build_images.sh index 04dfe324c..1d29ae5b1 100755 --- a/docker/coolify/build_images.sh +++ b/docker/coolify/build_images.sh @@ -7,7 +7,6 @@ cd ../../ declare -A services=( ["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile" ["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile" - ["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile" ["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile" ) diff --git a/docker/coolify/redis.Dockerfile b/docker/coolify/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/coolify/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/dev/docker-compose.custom-ca-example.yaml b/docker/dev/docker-compose.custom-ca-example.yaml index a5d7e2f43..e1f185ae9 100644 --- a/docker/dev/docker-compose.custom-ca-example.yaml +++ b/docker/dev/docker-compose.custom-ca-example.yaml @@ -19,7 +19,6 @@ services: # Mount the certs directory (read-only for security) - ./certs:/certs:ro depends_on: - - redis - mongodb # Optional: Add healthcheck to ensure the server starts properly healthcheck: diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml index a2c66c027..09c12fe18 100755 --- a/docker/dev/docker-compose.yaml +++ b/docker/dev/docker-compose.yaml @@ -20,9 +20,35 @@ services: - server.env depends_on: - mongodb + worker: + image: uptime_server:latest + restart: always + ports: + - "52346:52346" + env_file: + - server.env + environment: + QUEUE_MODE: "worker" + HEALTH_PORT: "52346" + stop_grace_period: 30s + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:52346/livez',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))", + ] + interval: 15s + timeout: 3s + retries: 3 + depends_on: + - mongodb mongodb: image: uptime_mongo:latest restart: always + ports: + - "27017:27017" command: ["mongod", "--quiet", "--bind_ip_all"] volumes: - ./mongo/data:/data/db diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index b9aba057c..49f81fe9a 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -9,7 +9,6 @@ services: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret depends_on: diff --git a/server/.env.example b/server/.env.example index 92798b56b..5a44935b8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,7 +11,8 @@ LOG_LEVEL=debug # Database # -------- DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db -QUEUE_TYPE=superSimpleQueue +QUEUE_MODE="primary" +QUEUE_PRIMARY_PROCESSES="true" # JWT Authentication # ------------------ diff --git a/server/eslint.config.js b/server/eslint.config.js index 817f54bc2..b10e75f6b 100755 --- a/server/eslint.config.js +++ b/server/eslint.config.js @@ -39,7 +39,7 @@ export default [ }, }, rules: { - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_' + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], // Ignore unused variables that start with '_' "@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types "mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing }, diff --git a/server/jest.config.ts b/server/jest.config.ts index 1faf98181..c56ce1419 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -8,7 +8,6 @@ const config: Config = { "^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }], }, moduleNameMapper: { - "^@/validation/(.*)\\.js$": "/src/validation/$1.js", "^@/utils/(.*)\\.js$": "/src/utils/$1.ts", "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", diff --git a/server/nodemon.json b/server/nodemon.json index 3faf346d3..37e41fc60 100755 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,5 +1,5 @@ { "ignore": ["node_modules/*"], - "watch": ["src/**/*.ts", "src/**/*.js", "*.json"], + "watch": ["src/**/*.ts", "src/**/*.js", "*.json", ".env"], "ext": "ts,js,json" } diff --git a/server/openapi.json b/server/openapi.json index 2a5abd6e0..4b710b639 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Checkmate API", "version": "1.0.0", - "description": "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts" + "description": "Generated from Zod validation schemas. Source of truth: server/src/api/validation/*.ts" }, "servers": [ { @@ -386,6 +386,10 @@ "type": "string", "enum": ["equal", "include", "regex"] }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, "notifications": { "type": "array", "items": { @@ -398,6 +402,12 @@ "type": "string" } }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, "secret": { "type": "string" }, @@ -460,6 +470,9 @@ }, "updatedAt": { "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" } }, "required": [ @@ -474,6 +487,7 @@ "statusWindowThreshold", "ignoreTlsErrors", "useAdvancedMatching", + "method", "notifications", "tags", "cpuAlertThreshold", @@ -487,7 +501,8 @@ "teamId", "userId", "createdAt", - "updatedAt" + "updatedAt", + "lastEvaluatedAt" ], "additionalProperties": { "nullable": true @@ -7016,6 +7031,13 @@ "type": "string" } }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + }, + "default": [] + }, "secret": { "type": "string" }, @@ -7053,6 +7075,10 @@ } ] }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, "gameId": { "anyOf": [ { @@ -7653,6 +7679,11 @@ } ] }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"], + "default": "GET" + }, "url": { "type": "string", "minLength": 1 @@ -7685,6 +7716,13 @@ }, "default": [] }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + }, + "default": [] + }, "secret": { "type": "string" }, @@ -8209,6 +8247,12 @@ "type": "string" } }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, "secret": { "type": "string" }, @@ -8252,6 +8296,10 @@ } ] }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, "port": { "type": "number", "minimum": 1, @@ -10422,6 +10470,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10461,6 +10520,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { @@ -10625,6 +10688,17 @@ "type": "string", "pattern": "^[a-zA-Z0-9_-]+$" }, + "customDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$" + }, + { + "nullable": true + } + ] + }, "timezone": { "type": "string" }, @@ -10664,6 +10738,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { @@ -10894,6 +10972,102 @@ } } }, + "/status-page/resolve": { + "get": { + "tags": ["status-page"], + "summary": "Resolve a published status page by custom domain", + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "required": true, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "timeFrame", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "domain", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "404": { + "description": "Status page not found" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/status-page/{url}": { "get": { "tags": ["status-page"], diff --git a/server/openapi/index.ts b/server/openapi/index.ts index 9758c9d54..ef3e0b1d0 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -28,7 +28,7 @@ export function getOpenApiSpec(): JsonObject { info: { title: "Checkmate API", version: "1.0.0", - description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts", + description: "Generated from Zod validation schemas. Source of truth: server/src/api/validation/*.ts", }, servers: [{ url: "/api/v1", description: "Current server" }], tags: [ diff --git a/server/openapi/routes/auth.ts b/server/openapi/routes/auth.ts index 66b74382b..72f4cd2d2 100644 --- a/server/openapi/routes/auth.ts +++ b/server/openapi/routes/auth.ts @@ -7,7 +7,7 @@ import { recoveryValidation, recoveryTokenBodyValidation, newPasswordValidation, -} from "@/validation/authValidation.js"; +} from "@/api/validation/authValidation.js"; import { getUserByIdParamValidation, editUserByIdParamValidation, @@ -16,7 +16,7 @@ import { createUserBodyValidation, editUserBodyValidation, userResponseSchema, -} from "@/validation/userValidation.js"; +} from "@/api/validation/userValidation.js"; const tags = ["auth"]; diff --git a/server/openapi/routes/check.ts b/server/openapi/routes/check.ts index a9b6b3804..98ec87b7b 100644 --- a/server/openapi/routes/check.ts +++ b/server/openapi/routes/check.ts @@ -7,7 +7,7 @@ import { getTeamChecksQueryValidation, deleteChecksParamValidation, getChecksSummaryByTeamIdQueryValidation, -} from "@/validation/checkValidation.js"; +} from "@/api/validation/checkValidation.js"; const tags = ["checks"]; diff --git a/server/openapi/routes/geoCheck.ts b/server/openapi/routes/geoCheck.ts index 7b90d0a0d..2650fb3be 100644 --- a/server/openapi/routes/geoCheck.ts +++ b/server/openapi/routes/geoCheck.ts @@ -1,6 +1,6 @@ import { registry } from "../registry.js"; import { bearer, okUnknown, standardErrors } from "../helpers.js"; -import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/api/validation/checkValidation.js"; registry.registerPath({ method: "get", diff --git a/server/openapi/routes/incident.ts b/server/openapi/routes/incident.ts index 6497a5bd0..6621adc2b 100644 --- a/server/openapi/routes/incident.ts +++ b/server/openapi/routes/incident.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { registry } from "../registry.js"; import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js"; -import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js"; +import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/api/validation/incidentValidation.js"; const tags = ["incidents"]; diff --git a/server/openapi/routes/invite.ts b/server/openapi/routes/invite.ts index bf9b568b3..7e31bd2e0 100644 --- a/server/openapi/routes/invite.ts +++ b/server/openapi/routes/invite.ts @@ -1,7 +1,7 @@ import { registry } from "../registry.js"; import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js"; import { z } from "zod"; -import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js"; const tags = ["invite"]; diff --git a/server/openapi/routes/maintenanceWindow.ts b/server/openapi/routes/maintenanceWindow.ts index 6afc57031..8d0506846 100644 --- a/server/openapi/routes/maintenanceWindow.ts +++ b/server/openapi/routes/maintenanceWindow.ts @@ -8,7 +8,7 @@ import { deleteMaintenanceWindowByIdParamValidation, editMaintenanceWindowByIdParamValidation, editMaintenanceByIdWindowBodyValidation, -} from "@/validation/maintenanceWindowValidation.js"; +} from "@/api/validation/maintenanceWindowValidation.js"; const tags = ["maintenance-window"]; diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index 10f988fa8..a941e54bf 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -16,8 +16,8 @@ import { getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, monitorResponseSchema, -} from "@/validation/monitorValidation.js"; -import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; +} from "@/api/validation/monitorValidation.js"; +import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; const tags = ["monitors"]; diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index c66e387f6..f1c70ca6d 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -7,7 +7,7 @@ import { getNotificationByIdParamValidation, editNotificationParamValidation, testAllNotificationsBodyValidation, -} from "@/validation/notificationValidation.js"; +} from "@/api/validation/notificationValidation.js"; const tags = ["notifications"]; diff --git a/server/openapi/routes/settings.ts b/server/openapi/routes/settings.ts index 1297c3e85..4871d9c7b 100644 --- a/server/openapi/routes/settings.ts +++ b/server/openapi/routes/settings.ts @@ -1,7 +1,7 @@ import { registry } from "../registry.js"; import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; -import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; -import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js"; +import { updateAppSettingsBodyValidation } from "@/api/validation/settingsValidation.js"; +import { sendTestEmailBodyValidation } from "@/api/validation/notificationValidation.js"; const tags = ["settings"]; diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts index ac315b31c..7ecc6e6d7 100644 --- a/server/openapi/routes/statusPage.ts +++ b/server/openapi/routes/statusPage.ts @@ -1,6 +1,11 @@ import { registry } from "../registry.js"; import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; -import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + resolveStatusPageQueryValidation, +} from "@/api/validation/statusPageValidation.js"; const tags = ["status-page"]; @@ -33,6 +38,15 @@ registry.registerPath({ responses: { "200": okUnknown, ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/status-page/resolve", + tags, + summary: "Resolve a published status page by custom domain", + request: { query: resolveStatusPageQueryValidation }, + responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, +}); + registry.registerPath({ method: "get", path: "/status-page/{url}", diff --git a/server/package-lock.json b/server/package-lock.json index 0f16e6b5d..9255f9889 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", - "@pulsecron/pulse": "1.6.8", "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", @@ -26,12 +26,10 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", - "ioredis": "^5.4.2", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.6.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -40,7 +38,6 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.9.2", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", "ws": "^8.19.0", @@ -74,6 +71,7 @@ "globals": "^15.14.0", "jest": "^30.2.0", "lint-staged": "^16.2.7", + "mongodb-memory-server": "11.2.0", "nodemon": "^3.1.11", "prettier": "^3.3.3", "prettier-plugin-ini": "1.3.0", @@ -839,12 +837,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -853,9 +851,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -863,22 +861,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -905,14 +903,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -922,14 +920,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -959,9 +957,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -969,29 +967,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1001,9 +999,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -1011,9 +1009,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -1021,18 +1019,18 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -1040,27 +1038,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1167,13 +1165,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1293,13 +1291,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1318,33 +1316,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1352,14 +1350,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1371,6 +1369,13 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.0" }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1549,32 +1554,10 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", - "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1583,9 +1566,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -1600,9 +1583,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -1617,9 +1600,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -1634,9 +1617,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -1651,9 +1634,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -1668,9 +1651,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -1685,9 +1668,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -1702,9 +1685,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -1719,9 +1702,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -1736,9 +1719,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -1753,9 +1736,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -1770,9 +1753,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -1787,9 +1770,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -1804,9 +1787,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -1821,9 +1804,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -1838,9 +1821,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -1855,9 +1838,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -1872,9 +1855,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -1889,9 +1872,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -1906,9 +1889,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -1923,9 +1906,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -1940,9 +1923,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -1957,9 +1940,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -1974,9 +1957,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -1991,9 +1974,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -2008,9 +1991,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -2182,9 +2165,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -2625,12 +2608,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", - "license": "MIT" - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2766,17 +2743,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2784,39 +2761,39 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2832,9 +2809,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -2842,39 +2819,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2885,18 +2862,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2913,62 +2890,62 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2985,17 +2962,10 @@ } } }, - "node_modules/@jest/reporters/node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3006,13 +2976,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -3037,14 +3007,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -3053,15 +3023,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3069,24 +3039,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -3096,14 +3065,14 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -3184,16 +3153,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodable/entities": { @@ -3258,13 +3233,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -3309,12 +3284,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -3333,20 +3302,6 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, - "node_modules/@pulsecron/pulse": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@pulsecron/pulse/-/pulse-1.6.8.tgz", - "integrity": "sha512-fg9sT/pfpZTlUYr/ktDu6om6XEm2zmulcZ8TP4JZa+Dmol7/35mMKzDwT1H5a0AUcxiFIRfB4KOpkJ+BI2fgmg==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.9.0", - "date.js": "~0.3.3", - "debug": "~4.3.4", - "human-interval": "~2.0.1", - "moment-timezone": "^0.5.45", - "mongodb": "^6.5.0" - } - }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3361,9 +3316,9 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.47", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", - "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, "license": "MIT" }, @@ -3390,9 +3345,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4116,9 +4071,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -4976,9 +4931,9 @@ "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -4990,9 +4945,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -5004,9 +4959,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -5018,9 +4973,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -5032,9 +4987,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -5046,9 +5001,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -5060,9 +5015,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -5074,9 +5029,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], @@ -5088,9 +5043,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], @@ -5101,10 +5056,38 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ "ppc64" ], @@ -5116,9 +5099,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ "riscv64" ], @@ -5130,9 +5113,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "cpu": [ "riscv64" ], @@ -5144,9 +5127,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "cpu": [ "s390x" ], @@ -5158,9 +5141,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], @@ -5172,9 +5155,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], @@ -5185,10 +5168,24 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -5196,16 +5193,52 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -5217,9 +5250,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -5231,9 +5264,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -5463,6 +5496,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5506,17 +5549,32 @@ "node": ">= 6" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -5586,9 +5644,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -5626,13 +5684,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -5648,6 +5706,104 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.2.tgz", + "integrity": "sha512-2V5j0dOfxv3iIngIWMnW1O6UPXpeoU70HJzUJGVth/+RURhDyq7SEWTD70Y2SkUB0MQonYYWpIX3f85P/I22+Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/barse": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/barse/-/barse-0.4.3.tgz", @@ -6348,15 +6504,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6450,6 +6597,13 @@ "node": ">=18" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -6675,18 +6829,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6911,30 +7053,6 @@ "node": ">=18" } }, - "node_modules/date.js": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/date.js/-/date.js-0.3.3.tgz", - "integrity": "sha512-HgigOS3h3k6HnW011nAb43c5xx5rBXk8P2v/WIT9Zv4koIaVXiH2BURguI78VVp+5Qc076T7OR378JViCnZtBw==", - "license": "MIT", - "dependencies": { - "debug": "~3.1.0" - } - }, - "node_modules/date.js/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/date.js/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -6986,9 +7104,9 @@ } }, "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7035,15 +7153,6 @@ "node": ">=0.4.0" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7203,9 +7312,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", - "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7435,9 +7544,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7448,32 +7557,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -7794,6 +7903,16 @@ "dev": true, "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7862,18 +7981,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7972,6 +8091,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -8162,6 +8288,50 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8243,16 +8413,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -8803,9 +8973,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8969,15 +9139,6 @@ "node": ">= 14" } }, - "node_modules/human-interval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/human-interval/-/human-interval-2.0.1.tgz", - "integrity": "sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==", - "license": "MIT", - "dependencies": { - "numbered": "^1.1.0" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -9101,30 +9262,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ioredis": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", - "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.4.0", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -9550,17 +9687,17 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -9578,14 +9715,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -9593,29 +9730,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -9625,21 +9762,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -9658,34 +9795,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -9710,25 +9846,25 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -9739,57 +9875,57 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -9799,50 +9935,64 @@ "fsevents": "^2.3.3" } }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -9850,16 +10000,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9884,9 +10047,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -9894,18 +10057,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -9914,46 +10077,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -9962,32 +10125,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -9996,9 +10159,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -10007,20 +10170,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -10029,18 +10192,18 @@ } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10060,18 +10223,18 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10091,19 +10254,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -10111,15 +10274,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -10159,9 +10322,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10416,95 +10589,6 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, - "node_modules/less-simple-scheduler": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.6.0.tgz", - "integrity": "sha512-Vi8ouiodLqe2fyh+DqCmxE5cCJZDI3xOyUAH4jcj9sf7GunLUzH71PBcYJy2P8lxTK3LxEkHtr1reymiogs6JQ==", - "license": "ISC", - "dependencies": { - "mongodb": "7.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/less-simple-scheduler/node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, - "node_modules/less-simple-scheduler/node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/less-simple-scheduler/node_modules/mongodb": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", - "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.2.0", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/less-simple-scheduler/node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10684,24 +10768,12 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -10890,15 +10962,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/mailersend": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.6.0.tgz", @@ -11583,27 +11646,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, "node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", @@ -11660,6 +11702,170 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, + "node_modules/mongodb-memory-server": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "11.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "^7.2.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.8", + "tslib": "^2.8.1", + "yauzl": "^3.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.3.0.tgz", + "integrity": "sha512-WpCqSx7JAU9vcyjm/SU7ydnHls2YrfU3Y3sx4Ml9D7sPe4mXPlaapndiurDXrQ7/VvJkB4/i7b7WovHb8bd8sg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/mongoose": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", @@ -11818,6 +12024,19 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -12000,12 +12219,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/numbered": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/numbered/-/numbered-1.1.0.tgz", - "integrity": "sha512-pv/ue2Odr7IfYOO0byC1KgBI10wo5YDauLhxY6/saNzAdAs0r1SotGCPzzCLNPL0xtrAwWRialLu23AAu9xO1g==", - "license": "MIT" - }, "node_modules/nwsapi": { "version": "2.2.22", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", @@ -12338,6 +12551,13 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13119,15 +13339,16 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -13153,9 +13374,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13165,7 +13386,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -13359,13 +13579,22 @@ "node": ">= 0.8" } }, - "node_modules/react-is": { + "node_modules/react-is-18": { + "name": "react-is", "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -13392,27 +13621,6 @@ "node": ">=8.10.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13654,9 +13862,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14083,12 +14291,6 @@ "node": ">=8" } }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -14106,6 +14308,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.27.0.tgz", + "integrity": "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -14366,28 +14580,6 @@ "postcss": "^8.4.32" } }, - "node_modules/super-simple-scheduler": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.2.tgz", - "integrity": "sha512-dfeI1R8jFLeHXIrNsDRxqvdJN2r4BjwKrvv/pPThu0X4RQa008/ubkFno96esHad04J6KyWYTK+P/qgOcoaReg==", - "license": "MIT", - "dependencies": { - "uuid": "11.1.1" - } - }, - "node_modules/super-simple-scheduler/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14432,13 +14624,13 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -14475,6 +14667,26 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -14633,19 +14845,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -14662,7 +14874,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -14942,38 +15154,41 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { @@ -15678,6 +15893,19 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/server/package.json b/server/package.json index 2812b9b1f..f44dd7031 100755 --- a/server/package.json +++ b/server/package.json @@ -22,7 +22,7 @@ "format-check": "prettier --check ." }, "lint-staged": { - "**/*": "prettier --write" + "**/*": "prettier --write --ignore-unknown" }, "keywords": [], "author": "", @@ -31,9 +31,9 @@ "node": ">=20" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", - "@pulsecron/pulse": "1.6.8", "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", @@ -48,12 +48,10 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", - "ioredis": "^5.4.2", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.6.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -62,7 +60,6 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.9.2", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", "ws": "^8.19.0", @@ -96,6 +93,7 @@ "globals": "^15.14.0", "jest": "^30.2.0", "lint-staged": "^16.2.7", + "mongodb-memory-server": "11.2.0", "nodemon": "^3.1.11", "prettier": "^3.3.3", "prettier-plugin-ini": "1.3.0", diff --git a/server/scripts/seed-monitors.js b/server/scripts/seed-monitors.js new file mode 100644 index 000000000..f62fe2eb3 --- /dev/null +++ b/server/scripts/seed-monitors.js @@ -0,0 +1,269 @@ +import mongoose from "mongoose"; +import { MonitorModel } from "../dist/domain/monitors/monitor.model.js"; +import { UserModel } from "../dist/domain/users/user.model.js"; + +// Seeds a large number of HTTP monitors against real, resolving domains so the +// scheduler can be exercised under load. Run after `npm run build` (imports the +// compiled models from dist/, like scripts/generate-checks.js). +// +// node scripts/seed-monitors.js # 10k monitors, owner auto-detected +// COUNT=2000 INTERVAL=30000 node scripts/seed-monitors.js +// CLEAR=true node scripts/seed-monitors.js # remove prior seed monitors first +// TEAM_ID= USER_ID= node scripts/seed-monitors.js +// +// NOTE: the running queue only schedules monitors it finds at init(), so restart +// the server after seeding for the new monitors to actually start checking. + +const DEFAULT_COUNT = 500; +const DEFAULT_BATCH = 1_000; +const DEFAULT_INTERVAL = 60_000; +const NAME_PREFIX = "Seed"; +// Stored in `description` so a re-run with CLEAR=true can find and remove them. +const SEED_MARKER = "checkmate-scaling-seed"; + +// A spread of well-known, reliably-resolving domains across regions, TLDs and +// CDNs. Monitors round-robin through this pool, so the load is split across many +// hosts rather than hammering one. Every entry returns a real HTTP response +// (some may 403/429 under load — still a valid response for scaling purposes). +const DOMAINS = [ + "google.com", + "youtube.com", + "facebook.com", + "wikipedia.org", + "amazon.com", + "reddit.com", + "x.com", + "instagram.com", + "linkedin.com", + "microsoft.com", + "apple.com", + "cloudflare.com", + "github.com", + "gitlab.com", + "stackoverflow.com", + "netflix.com", + "bing.com", + "yahoo.com", + "duckduckgo.com", + "mozilla.org", + "wordpress.org", + "wordpress.com", + "adobe.com", + "salesforce.com", + "oracle.com", + "ibm.com", + "intel.com", + "nvidia.com", + "amd.com", + "dell.com", + "hp.com", + "cisco.com", + "vmware.com", + "redhat.com", + "ubuntu.com", + "debian.org", + "kernel.org", + "python.org", + "nodejs.org", + "npmjs.com", + "docker.com", + "kubernetes.io", + "golang.org", + "rust-lang.org", + "php.net", + "ruby-lang.org", + "java.com", + "spring.io", + "djangoproject.com", + "flask.palletsprojects.com", + "vuejs.org", + "reactjs.org", + "angular.io", + "svelte.dev", + "jquery.com", + "npm.im", + "pypi.org", + "rubygems.org", + "packagist.org", + "maven.apache.org", + "apache.org", + "nginx.org", + "postgresql.org", + "mysql.com", + "mongodb.com", + "redis.io", + "elastic.co", + "grafana.com", + "prometheus.io", + "hashicorp.com", + "digitalocean.com", + "linode.com", + "vultr.com", + "heroku.com", + "netlify.com", + "vercel.com", + "render.com", + "fly.io", + "railway.app", + "cloudinary.com", + "twilio.com", + "stripe.com", + "paypal.com", + "shopify.com", + "squarespace.com", + "wix.com", + "godaddy.com", + "namecheap.com", + "name.com", + "gandi.net", + "bbc.co.uk", + "theguardian.com", + "nytimes.com", + "washingtonpost.com", + "cnn.com", + "reuters.com", + "bloomberg.com", + "forbes.com", + "wsj.com", + "economist.com", + "nature.com", + "sciencedirect.com", + "arxiv.org", + "mit.edu", + "stanford.edu", + "harvard.edu", + "berkeley.edu", + "ox.ac.uk", + "cam.ac.uk", + "ethz.ch", + "spotify.com", + "soundcloud.com", + "twitch.tv", + "vimeo.com", + "dailymotion.com", + "imdb.com", + "wikimedia.org", + "archive.org", + "ietf.org", + "w3.org", + "who.int", + "un.org", + "europa.eu", + "gov.uk", + "canada.ca", + "alibaba.com", + "aliexpress.com", + "baidu.com", + "tencent.com", + "naver.com", + "rakuten.co.jp", + "yandex.com", + "ozon.ru", + "mercadolibre.com", + "flipkart.com", + "booking.com", + "airbnb.com", + "expedia.com", + "tripadvisor.com", + "uber.com", + "dropbox.com", + "box.com", + "atlassian.com", + "trello.com", + "notion.so", + "slack.com", + "zoom.us", + "asana.com", + "figma.com", + "canva.com", +]; + +const parseObjectId = (value) => { + if (!value) return null; + try { + return new mongoose.Types.ObjectId(value); + } catch { + console.warn(`Ignoring invalid ObjectId '${value}'`); + return null; + } +}; + +// Monitors need a real teamId/userId to show up for an account and to be picked +// up by the queue. Prefer explicit env ids, otherwise derive from the oldest +// existing user (the typical single-tenant dev setup). +async function resolveOwner() { + const teamId = parseObjectId(process.env.TEAM_ID); + const userId = parseObjectId(process.env.USER_ID); + if (teamId && userId) return { teamId, userId }; + + const user = await UserModel.findOne(userId ? { _id: userId } : {}).sort({ createdAt: 1 }); + if (!user?.teamId) { + throw new Error( + "Could not determine an owner: no existing user with a teamId. " + "Create an account first, or pass TEAM_ID and USER_ID explicitly." + ); + } + return { teamId: teamId ?? user.teamId, userId: user._id }; +} + +async function run() { + const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db"; + const total = Number(process.env.COUNT ?? DEFAULT_COUNT); + const batchSize = Number(process.env.BATCH_SIZE ?? DEFAULT_BATCH); + const interval = Number(process.env.INTERVAL ?? DEFAULT_INTERVAL); + + console.log(`Connecting to MongoDB at ${mongoUri}`); + await mongoose.connect(mongoUri); + + const { teamId, userId } = await resolveOwner(); + + if (process.env.CLEAR === "true") { + const { deletedCount } = await MonitorModel.deleteMany({ description: SEED_MARKER }); + console.log(`Removed ${deletedCount} previously seeded monitors`); + } + + console.log( + `Seeding ${total} http monitors for team ${teamId} (user ${userId}) across ${DOMAINS.length} domains, ` + + `interval ${interval}ms, in batches of ${batchSize}` + ); + + const docs = []; + const startTime = Date.now(); + let inserted = 0; + + for (let i = 0; i < total; i += 1) { + const domain = DOMAINS[i % DOMAINS.length]; + docs.push({ + userId, + teamId, + name: `${NAME_PREFIX} ${String(i + 1).padStart(5, "0")} ${domain}`, + description: SEED_MARKER, + type: "http", + url: `https://${domain}`, + isActive: true, + interval, + lastEvaluatedAt: 0, // evaluator watermark; without it findUnevaluatedByMonitorId gets new Date(undefined) + }); + + if (docs.length === batchSize) { + await MonitorModel.insertMany(docs, { ordered: false }); + inserted += docs.length; + console.log(`Inserted ${inserted} / ${total}`); + docs.length = 0; + } + } + + if (docs.length > 0) { + await MonitorModel.insertMany(docs, { ordered: false }); + inserted += docs.length; + } + + await mongoose.disconnect(); + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`Finished inserting ${inserted} monitors in ${duration}s`); + console.log("Restart the server so the queue schedules the new monitors."); +} + +run().catch((error) => { + console.error("Failed to seed monitors", error); + process.exit(1); +}); diff --git a/server/src/controllers/authController.ts b/server/src/api/controllers/authController.ts similarity index 97% rename from server/src/controllers/authController.ts rename to server/src/api/controllers/authController.ts index 20303b4eb..45f806a8c 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/api/controllers/authController.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { AppError } from "@/utils/AppError.js"; -import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js"; +import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/api/controllers/controllerUtils.js"; import { registrationBodyValidation, @@ -8,7 +8,7 @@ import { recoveryValidation, recoveryTokenBodyValidation, newPasswordValidation, -} from "@/validation/authValidation.js"; +} from "@/api/validation/authValidation.js"; import { editUserBodyValidation, @@ -18,8 +18,8 @@ import { editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, editUserPasswordByIdBodyValidation, -} from "@/validation/userValidation.js"; -import { IUserService } from "@/service/index.js"; +} from "@/api/validation/userValidation.js"; +import { IUserService } from "@/domain/users/user.service.js"; const SERVICE_NAME = "authController"; diff --git a/server/src/controllers/checkController.ts b/server/src/api/controllers/checkController.ts similarity index 95% rename from server/src/controllers/checkController.ts rename to server/src/api/controllers/checkController.ts index dfe64e80a..23fbe2227 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/api/controllers/checkController.ts @@ -6,9 +6,9 @@ import { getTeamChecksQueryValidation, getChecksSummaryByTeamIdQueryValidation, deleteChecksParamValidation, -} from "@/validation/checkValidation.js"; -import { ICheckService } from "@/service/index.js"; -import { requireTeamId } from "@/controllers/controllerUtils.js"; +} from "@/api/validation/checkValidation.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { requireTeamId } from "@/api/controllers/controllerUtils.js"; const SERVICE_NAME = "checkController"; diff --git a/server/src/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts similarity index 96% rename from server/src/controllers/controllerUtils.ts rename to server/src/api/controllers/controllerUtils.ts index 67dc71a70..45bf3fa9f 100755 --- a/server/src/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -1,5 +1,6 @@ import { AppError } from "@/utils/AppError.js"; -import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js"; +import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js"; +import { UserRole } from "@/domain/users/user.type.js"; import sslChecker, { SSLDetails } from "ssl-checker"; type SSLCheckerType = typeof sslChecker; diff --git a/server/src/controllers/diagnosticController.ts b/server/src/api/controllers/diagnosticController.ts similarity index 91% rename from server/src/controllers/diagnosticController.ts rename to server/src/api/controllers/diagnosticController.ts index ffe7c6f5f..efc383e1a 100755 --- a/server/src/controllers/diagnosticController.ts +++ b/server/src/api/controllers/diagnosticController.ts @@ -1,4 +1,4 @@ -import { IDiagnosticService } from "@/service/index.js"; +import { IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js"; import { Request, Response, NextFunction } from "express"; const SERVICE_NAME = "diagnosticController"; diff --git a/server/src/controllers/geoCheckController.ts b/server/src/api/controllers/geoCheckController.ts similarity index 90% rename from server/src/controllers/geoCheckController.ts rename to server/src/api/controllers/geoCheckController.ts index 96f9beeac..a50176188 100644 --- a/server/src/controllers/geoCheckController.ts +++ b/server/src/api/controllers/geoCheckController.ts @@ -1,8 +1,7 @@ import { Request, Response, NextFunction } from "express"; -import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js"; -import type { IGeoChecksService } from "@/service/business/geoChecksService.js"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/api/validation/checkValidation.js"; +import type { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; import { requireTeamId } from "./controllerUtils.js"; -import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "geoCheckController"; diff --git a/server/src/controllers/incidentController.ts b/server/src/api/controllers/incidentController.ts similarity index 96% rename from server/src/controllers/incidentController.ts rename to server/src/api/controllers/incidentController.ts index 03f1c7fe4..6358d2a89 100644 --- a/server/src/controllers/incidentController.ts +++ b/server/src/api/controllers/incidentController.ts @@ -1,8 +1,8 @@ import { AppError } from "@/utils/AppError.js"; import { Request, Response, NextFunction } from "express"; import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js"; -import { IIncidentService } from "@/service/index.js"; -import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js"; +import { IIncidentService } from "@/domain/incidents/incident.service.js"; +import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/api/validation/incidentValidation.js"; const SERVICE_NAME = "IncidentController"; diff --git a/server/src/controllers/inviteController.ts b/server/src/api/controllers/inviteController.ts similarity index 94% rename from server/src/controllers/inviteController.ts rename to server/src/api/controllers/inviteController.ts index 700a7bc6a..c77385f7b 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/api/controllers/inviteController.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; -import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js"; -import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js"; -import { IInviteService } from "@/service/index.js"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/api/validation/authValidation.js"; +import { requireFirstName, requireTeamId, requireUserRoles } from "@/api/controllers/controllerUtils.js"; +import { IInviteService } from "@/domain/invites/invite.service.js"; const SERVICE_NAME = "inviteController"; export interface IInviteController { diff --git a/server/src/controllers/logController.ts b/server/src/api/controllers/logController.ts similarity index 100% rename from server/src/controllers/logController.ts rename to server/src/api/controllers/logController.ts diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/api/controllers/maintenanceWindowController.ts similarity index 95% rename from server/src/controllers/maintenanceWindowController.ts rename to server/src/api/controllers/maintenanceWindowController.ts index 441acfa80..ddbf4deca 100644 --- a/server/src/controllers/maintenanceWindowController.ts +++ b/server/src/api/controllers/maintenanceWindowController.ts @@ -7,9 +7,9 @@ import { getMaintenanceWindowsByMonitorIdParamValidation, getMaintenanceWindowsByTeamIdQueryValidation, deleteMaintenanceWindowByIdParamValidation, -} from "@/validation/maintenanceWindowValidation.js"; -import { requireTeamId } from "@/controllers/controllerUtils.js"; -import { IMaintenanceWindowService } from "@/service/index.js"; +} from "@/api/validation/maintenanceWindowValidation.js"; +import { requireTeamId } from "@/api/controllers/controllerUtils.js"; +import { IMaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js"; export interface IMaintenanceWindowController { createMaintenanceWindows: (req: Request, res: Response, next: NextFunction) => Promise; diff --git a/server/src/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts similarity index 97% rename from server/src/controllers/monitorController.ts rename to server/src/api/controllers/monitorController.ts index 25946ccdf..a0cb073d2 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; +import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; import { getMonitorByIdParamValidation, getMonitorByIdQueryValidation, @@ -15,11 +15,12 @@ import { getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, bulkPauseMonitorBodyValidation, -} from "@/validation/monitorValidation.js"; +} from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; -import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; +import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; -import { IMonitorService, INotificationsService } from "@/service/index.js"; +import { IMonitorService } from "@/domain/monitors/monitor.service.js"; +import { INotificationsService } from "@/domain/notifications/notification.service.js"; const SERVICE_NAME = "monitorController"; diff --git a/server/src/controllers/notificationController.ts b/server/src/api/controllers/notificationController.ts similarity index 95% rename from server/src/controllers/notificationController.ts rename to server/src/api/controllers/notificationController.ts index 301c15446..3aecfcad8 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/api/controllers/notificationController.ts @@ -7,11 +7,11 @@ import { testNotificationBodyValidation, editNotificationParamValidation, testAllNotificationsBodyValidation, -} from "@/validation/notificationValidation.js"; +} from "@/api/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; -import { INotificationsService } from "@/service/index.js"; +import { INotificationsService } from "@/domain/notifications/notification.service.js"; import { requireTeamId, requireUserId } from "./controllerUtils.js"; -import { IMonitorsRepository } from "@/repositories/index.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; const SERVICE_NAME = "NotificationController"; diff --git a/server/src/controllers/queueController.ts b/server/src/api/controllers/queueController.ts similarity index 77% rename from server/src/controllers/queueController.ts rename to server/src/api/controllers/queueController.ts index 3b0bb64ce..0a21761ab 100755 --- a/server/src/controllers/queueController.ts +++ b/server/src/api/controllers/queueController.ts @@ -1,5 +1,5 @@ -import { IJobQueue } from "@/service/index.js"; -import { getQueueJobsQueryValidation } from "@/validation/queueValidation.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; +import { getQueueJobsQueryValidation } from "@/api/validation/queueValidation.js"; import { Request, Response, NextFunction } from "express"; const SERVICE_NAME = "JobQueueController"; @@ -13,10 +13,7 @@ export interface IJobQueueController { class JobQueueController implements IJobQueueController { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: IJobQueue; - constructor(jobQueue: IJobQueue) { - this.jobQueue = jobQueue; - } + constructor(private scheduler: IJobScheduler) {} get serviceName() { return JobQueueController.SERVICE_NAME; @@ -24,7 +21,7 @@ class JobQueueController implements IJobQueueController { getMetrics = async (req: Request, res: Response, next: NextFunction) => { try { - const metrics = await this.jobQueue.getMetrics(); + const metrics = await this.scheduler.getMetrics(); res.status(200).json({ success: true, msg: "Queue metrics fetched successfully", @@ -38,7 +35,7 @@ class JobQueueController implements IJobQueueController { getJobs = async (req: Request, res: Response, next: NextFunction) => { try { const pagination = getQueueJobsQueryValidation.parse(req.query); - const { jobs, count } = await this.jobQueue.getJobs(pagination); + const { jobs, count } = await this.scheduler.getJobs(pagination); return res.status(200).json({ success: true, msg: "Queue jobs fetched successfully", @@ -52,8 +49,8 @@ class JobQueueController implements IJobQueueController { getAllMetrics = async (req: Request, res: Response, next: NextFunction) => { try { const pagination = getQueueJobsQueryValidation.parse(req.query); - const { jobs, count } = await this.jobQueue.getJobs(pagination); - const metrics = await this.jobQueue.getMetrics(); + const { jobs, count } = await this.scheduler.getJobs(pagination); + const metrics = await this.scheduler.getMetrics(); return res.status(200).json({ success: true, msg: "Queue metrics fetched successfully", @@ -66,7 +63,7 @@ class JobQueueController implements IJobQueueController { flushQueue = async (req: Request, res: Response, next: NextFunction) => { try { - const result = await this.jobQueue.flushQueues(); + const result = await this.scheduler.flushQueues(); return res.status(200).json({ success: true, msg: "Queue flushed successfully", diff --git a/server/src/controllers/settingsController.ts b/server/src/api/controllers/settingsController.ts similarity index 91% rename from server/src/controllers/settingsController.ts rename to server/src/api/controllers/settingsController.ts index 485a90d64..4acfdf552 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/api/controllers/settingsController.ts @@ -1,9 +1,10 @@ import { Request, Response, NextFunction } from "express"; -import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; -import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js"; +import { updateAppSettingsBodyValidation } from "@/api/validation/settingsValidation.js"; +import { sendTestEmailBodyValidation } from "@/api/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; -import { IEmailService, ISettingsService } from "@/service/index.js"; -import { Settings } from "@/types/settings.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { IEmailService } from "@/service/emailService.js"; +import { Settings } from "@/domain/app-settings/app-settings.type.js"; const SERVICE_NAME = "SettingsController"; diff --git a/server/src/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts similarity index 61% rename from server/src/controllers/statusPageController.ts rename to server/src/api/controllers/statusPageController.ts index 190c3a9fd..394d76e23 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -5,13 +5,16 @@ import { getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation, -} from "@/validation/statusPageValidation.js"; + resolveStatusPageQueryValidation, +} from "@/api/validation/statusPageValidation.js"; import { AppError } from "@/utils/AppError.js"; -import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; -import { IStatusPageService } from "@/service/business/statusPageService.js"; -import { IMonitorsRepository } from "@/repositories/index.js"; -import { ISettingsService } from "@/service/system/settingsService.js"; +import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; +import { IStatusPageService } from "@/domain/status-pages/status-page.service.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { NormalizeData } from "@/utils/dataUtils.js"; +import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js"; +import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; const SERVICE_NAME = "statusPageController"; @@ -20,6 +23,7 @@ export interface IStatusPageController { createStatusPage(req: Request, res: Response, next: NextFunction): Promise; updateStatusPage(req: Request, res: Response, next: NextFunction): Promise; getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise; + resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise; getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise; deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise; } @@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController { return StatusPageController.SERVICE_NAME; } + private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => { + if (!statusPage.isPublished) { + const teamId = requireTeamId(req?.user?.teamId); + if (statusPage.teamId !== teamId) { + throw new AppError({ message: "Forbidden", status: 403 }); + } + } + + const settings = await this.settingsService.getDBSettings(); + const showURL = settings.showURL; + + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); + const sortedMonitors = [...monitors].sort((a, b) => { + const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + const normalizedMonitors = sortedMonitors.map((monitor) => { + const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); + if (!showURL) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { url, port, secret, notifications, ...rest } = monitor; + return { ...rest, checks: normalizedChecks }; + } + return { ...monitor, checks: normalizedChecks }; + }); + + return { + statusPage, + monitors: normalizedMonitors, + }; + }; + createStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { createStatusPageBodyValidation.parse(req.body); @@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController { } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); + const data = await this.buildStatusPagePayload(statusPage, req); - if (!statusPage.isPublished) { - const teamId = requireTeamId(req?.user?.teamId); - if (statusPage.teamId !== teamId) { - throw new AppError({ message: "Forbidden", status: 403 }); - } - } - - const settings = await this.settingsService.getDBSettings(); - const showURL = settings.showURL; - - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); - // Sort monitors according to the order in statusPage.monitors - const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index])); - const sortedMonitors = [...monitors].sort((a, b) => { - const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return orderA - orderB; - }); - - const normalizedMonitors = sortedMonitors.map((monitor) => { - const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100); - if (!showURL) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { url, port, secret, notifications, ...rest } = monitor; - return { ...rest, checks: normalizedChecks }; - } - return { ...monitor, checks: normalizedChecks }; - }); return res.status(200).json({ success: true, msg: "Status page retrieved successfully", - data: { - statusPage, - monitors: normalizedMonitors, - }, + data, }); } catch (error) { next(error); } }; + + resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => { + try { + resolveStatusPageQueryValidation.parse(req.query); + + const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); + if (!domain) { + throw new AppError({ message: "Domain is required", status: 400 }); + } + + const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain); + if (!statusPage.isPublished) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const data = await this.buildStatusPagePayload(statusPage, req); + + return res.status(200).json({ + success: true, + msg: "Status page retrieved successfully", + data, + }); + } catch (error) { + next(error); + } + }; + getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); diff --git a/server/src/controllers/statusPageThemeFlag.ts b/server/src/api/controllers/statusPageThemeFlag.ts similarity index 95% rename from server/src/controllers/statusPageThemeFlag.ts rename to server/src/api/controllers/statusPageThemeFlag.ts index bd65d5cb2..0fc2baedc 100644 --- a/server/src/controllers/statusPageThemeFlag.ts +++ b/server/src/api/controllers/statusPageThemeFlag.ts @@ -1,4 +1,4 @@ -import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/domain/status-pages/status-page.type.js"; /** * When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming diff --git a/server/src/controllers/tagController.ts b/server/src/api/controllers/tagController.ts similarity index 96% rename from server/src/controllers/tagController.ts rename to server/src/api/controllers/tagController.ts index 66b21dc6b..95dd81296 100644 --- a/server/src/controllers/tagController.ts +++ b/server/src/api/controllers/tagController.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { ITagsService } from "@/service/index.js"; +import { ITagsService } from "@/domain/tags/tag.service.js"; import { requireTeamId } from "./controllerUtils.js"; import { createTagBodyValidation, @@ -7,7 +7,7 @@ import { getTagByIdParamValidation, editTagParamValidation, deleteTagParamValidation, -} from "@/validation/index.js"; +} from "@/api/validation/index.js"; export interface ITagsController { createTag: (req: Request, res: Response, next: NextFunction) => Promise; diff --git a/server/src/api/middleware/handleErrors.ts b/server/src/api/middleware/handleErrors.ts new file mode 100755 index 000000000..77817f12e --- /dev/null +++ b/server/src/api/middleware/handleErrors.ts @@ -0,0 +1,27 @@ +import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; +import type { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; + +// Multer throws MulterError, send 413 Payload Too Large / 400 +const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400); + +const handleErrors = (logger: ILogger) => (error: unknown, req: Request, res: Response, _next: NextFunction) => { + const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500; + const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; + const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService"; + const method = error instanceof AppError ? error.method : "unknownMethod"; + logger.error({ + message: message, + service: service, + method: method, + stack: error instanceof Error ? error.stack : undefined, + details: error instanceof AppError ? error.details : undefined, + }); + res.status(status).json({ + status, + msg: message, + }); +}; + +export { handleErrors }; diff --git a/server/src/middleware/isAllowed.ts b/server/src/api/middleware/isAllowed.ts similarity index 92% rename from server/src/middleware/isAllowed.ts rename to server/src/api/middleware/isAllowed.ts index 21690ee38..67cc68f46 100755 --- a/server/src/middleware/isAllowed.ts +++ b/server/src/api/middleware/isAllowed.ts @@ -1,7 +1,7 @@ import type { Request, Response, NextFunction } from "express"; const SERVICE_NAME = "allowedRoles"; import { AppError } from "@/utils/AppError.js"; -import type { UserRole } from "@/types/index.js"; +import type { UserRole } from "@/domain/users/user.type.js"; const isAllowed = (allowedRoles: UserRole[]) => { return (req: Request, res: Response, next: NextFunction) => { diff --git a/server/src/api/middleware/rateLimiter.ts b/server/src/api/middleware/rateLimiter.ts new file mode 100644 index 000000000..839f9377d --- /dev/null +++ b/server/src/api/middleware/rateLimiter.ts @@ -0,0 +1,19 @@ +import rateLimit from "express-rate-limit"; + +export const generalApiLimiter = (devMode: boolean) => + rateLimit({ + windowMs: 60 * 1000, + limit: 600, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, + skip: () => devMode, + }); + +export const authApiLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 15, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, +}); diff --git a/server/src/middleware/sanitization.ts b/server/src/api/middleware/sanitization.ts similarity index 100% rename from server/src/middleware/sanitization.ts rename to server/src/api/middleware/sanitization.ts diff --git a/server/src/api/middleware/statusPageCorsOrigin.ts b/server/src/api/middleware/statusPageCorsOrigin.ts new file mode 100644 index 000000000..338c43472 --- /dev/null +++ b/server/src/api/middleware/statusPageCorsOrigin.ts @@ -0,0 +1,68 @@ +import type { CorsOptions } from "cors"; +import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; + +const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000; +const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000; + +type CustomDomainCorsCacheEntry = { + allowed: boolean; + expiresAt: number; +}; + +const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number => + allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS; + +export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => { + const cache = new Map(); + + const cacheOriginResult = (customDomain: string, allowed: boolean) => { + cache.set(customDomain, { + allowed, + expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed), + }); + }; + + return (origin, callback) => { + if (!origin) { + callback(null, true); + return; + } + + if (origin === clientHost) { + callback(null, true); + return; + } + + let customDomain: string | null = null; + try { + customDomain = normalizeStatusPageDomain(new URL(origin).hostname); + } catch { + callback(null, false); + return; + } + + if (!customDomain) { + callback(null, false); + return; + } + + const cached = cache.get(customDomain); + if (cached && cached.expiresAt > Date.now()) { + callback(null, cached.allowed ? origin : false); + return; + } + + statusPagesRepository + .findByCustomDomain(customDomain) + .then((statusPage) => { + const allowed = statusPage.isPublished; + cacheOriginResult(customDomain, allowed); + callback(null, allowed ? origin : false); + }) + .catch(() => { + cacheOriginResult(customDomain, false); + callback(null, false); + }); + }; +}; diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 000000000..c5175b81d --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,27 @@ +import type { NextFunction, Request, Response } from "express"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; + +const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; + +// Browsers enforce the intersection of all CSP headers, so this only tightens +// the status page on top of the global helmet policy, blocking external images, +// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. +const STATUS_PAGE_CSP = [ + "img-src 'self' data:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +// A status page document is served on the public path or on a custom domain +// (any host other than the app's own). Mirrors the client isCustomDomainHost. +export const createStatusPageDocumentCsp = (clientHost: string) => { + const appHostname = normalizeStatusPageDomain(clientHost); + + return (req: Request, res: Response, next: NextFunction) => { + const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname; + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); + }; +}; diff --git a/server/src/api/middleware/statusPagePublicApiPath.ts b/server/src/api/middleware/statusPagePublicApiPath.ts new file mode 100644 index 000000000..a4b5e42d1 --- /dev/null +++ b/server/src/api/middleware/statusPagePublicApiPath.ts @@ -0,0 +1,24 @@ +const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page"; + +export const isPublicStatusPageApiPath = (method: string, path: string): boolean => { + if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) { + return false; + } + + if (method !== "GET" && method !== "OPTIONS") { + return false; + } + + const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? ""; + const slug = subPath.replace(/^\//, ""); + + if (slug === "resolve") { + return true; + } + + if (!slug || slug === "team" || slug.includes("/")) { + return false; + } + + return true; +}; diff --git a/server/src/api/middleware/upload.ts b/server/src/api/middleware/upload.ts new file mode 100644 index 000000000..c3f257884 --- /dev/null +++ b/server/src/api/middleware/upload.ts @@ -0,0 +1,27 @@ +import multer from "multer"; +import { AppError } from "@/utils/AppError.js"; +import { ImageMimeTypes, MAX_IMAGE_SIZE_BYTES } from "@/types/upload.js"; + +// Reusable multer instance for handling image uploads +const imageUpload = multer({ + limits: { + fileSize: MAX_IMAGE_SIZE_BYTES, + files: 1, + }, + fileFilter: (_req, file, cb) => { + if (!(ImageMimeTypes as readonly string[]).includes(file.mimetype)) { + cb( + new AppError({ + status: 415, + message: "File must be a valid image (jpeg, jpg, or png)", + service: "uploadMiddleware", + method: "fileFilter", + }) + ); + return; + } + cb(null, true); + }, +}); + +export { imageUpload }; diff --git a/server/src/middleware/verifyJWT.ts b/server/src/api/middleware/verifyJWT.ts similarity index 93% rename from server/src/middleware/verifyJWT.ts rename to server/src/api/middleware/verifyJWT.ts index 63218566c..0f0f28021 100644 --- a/server/src/middleware/verifyJWT.ts +++ b/server/src/api/middleware/verifyJWT.ts @@ -2,8 +2,8 @@ import { NextFunction, Request, Response } from "express"; import jwt from "jsonwebtoken"; import { AppError } from "@/utils/AppError.js"; -import type { ISettingsService } from "@/service/system/settingsService.js"; -import type { User } from "@/types/user.js"; +import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import type { User } from "@/domain/users/user.type.js"; const SERVICE_NAME = "verifyJWT"; const TOKEN_PREFIX = "Bearer "; diff --git a/server/src/middleware/verifyStatusPageAccess.ts b/server/src/api/middleware/verifyStatusPageAccess.ts similarity index 88% rename from server/src/middleware/verifyStatusPageAccess.ts rename to server/src/api/middleware/verifyStatusPageAccess.ts index 25ea29c51..bd2d062a3 100644 --- a/server/src/middleware/verifyStatusPageAccess.ts +++ b/server/src/api/middleware/verifyStatusPageAccess.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, RequestHandler, Response } from "express"; -import { IStatusPagesRepository } from "@/repositories/index.js"; +import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; import { AppError } from "@/utils/AppError.js"; export const createVerifyStatusPageAccess = (statusPagesRepository: IStatusPagesRepository, verifyJWT: RequestHandler) => { diff --git a/server/src/routes/authRoute.ts b/server/src/api/routes/authRoute.ts old mode 100755 new mode 100644 similarity index 74% rename from server/src/routes/authRoute.ts rename to server/src/api/routes/authRoute.ts index af6fd4266..3b202a502 --- a/server/src/routes/authRoute.ts +++ b/server/src/api/routes/authRoute.ts @@ -1,9 +1,7 @@ import { Router, RequestHandler } from "express"; -import { isAllowed } from "../middleware/isAllowed.js"; -import multer from "multer"; -import { IAuthController } from "@/controllers/authController.js"; - -const upload = multer(); +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { imageUpload } from "@/api/middleware/upload.js"; +import { IAuthController } from "@/api/controllers/authController.js"; class AuthRoutes { private router: Router; @@ -16,7 +14,7 @@ class AuthRoutes { } initRoutes(verifyJWT: RequestHandler) { - this.router.post("/register", upload.single("profileImage"), this.authController.registerUser); + this.router.post("/register", imageUpload.single("profileImage"), this.authController.registerUser); this.router.post("/login", this.authController.loginUser); this.router.post("/recovery/request", this.authController.requestRecovery); @@ -26,13 +24,19 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); - this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser); + this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), imageUpload.single("profileImage"), this.authController.createUser); this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById); this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); - this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); + this.router.patch( + "/user", + verifyJWT, + imageUpload.single("profileImage"), + isAllowed(["admin", "superadmin", "user"]), + this.authController.editUser + ); this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser); } diff --git a/server/src/routes/checkRoute.ts b/server/src/api/routes/checkRoute.ts old mode 100755 new mode 100644 similarity index 91% rename from server/src/routes/checkRoute.ts rename to server/src/api/routes/checkRoute.ts index 712bae969..171d79ab4 --- a/server/src/routes/checkRoute.ts +++ b/server/src/api/routes/checkRoute.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; -import { ICheckController } from "@/controllers/checkController.js"; +import { ICheckController } from "@/api/controllers/checkController.js"; class CheckRoutes { private router: Router; diff --git a/server/src/routes/diagnosticRoute.ts b/server/src/api/routes/diagnosticRoute.ts old mode 100755 new mode 100644 similarity index 88% rename from server/src/routes/diagnosticRoute.ts rename to server/src/api/routes/diagnosticRoute.ts index 32b857e8d..3fe29bc1e --- a/server/src/routes/diagnosticRoute.ts +++ b/server/src/api/routes/diagnosticRoute.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; -import { IDiagnosticController } from "@/controllers/diagnosticController.js"; +import { IDiagnosticController } from "@/api/controllers/diagnosticController.js"; class DiagnosticRoutes { private router: Router; diff --git a/server/src/routes/geoCheckRoutes.ts b/server/src/api/routes/geoCheckRoutes.ts similarity index 85% rename from server/src/routes/geoCheckRoutes.ts rename to server/src/api/routes/geoCheckRoutes.ts index 1fc48d7b3..bf9178afe 100644 --- a/server/src/routes/geoCheckRoutes.ts +++ b/server/src/api/routes/geoCheckRoutes.ts @@ -1,4 +1,4 @@ -import { IGeoCheckController } from "@/controllers/geoCheckController.js"; +import { IGeoCheckController } from "@/api/controllers/geoCheckController.js"; import { Router } from "express"; class GeoCheckRoutes { diff --git a/server/src/routes/incidentRoute.ts b/server/src/api/routes/incidentRoute.ts similarity index 91% rename from server/src/routes/incidentRoute.ts rename to server/src/api/routes/incidentRoute.ts index fdbb6f532..0f2f4485c 100644 --- a/server/src/routes/incidentRoute.ts +++ b/server/src/api/routes/incidentRoute.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; -import { IIncidentController } from "@/controllers/incidentController.js"; +import { IIncidentController } from "@/api/controllers/incidentController.js"; class IncidentRoutes { private router: Router; diff --git a/server/src/routes/inviteRoute.ts b/server/src/api/routes/inviteRoute.ts old mode 100755 new mode 100644 similarity index 91% rename from server/src/routes/inviteRoute.ts rename to server/src/api/routes/inviteRoute.ts index 4542bd21e..2e25694d2 --- a/server/src/routes/inviteRoute.ts +++ b/server/src/api/routes/inviteRoute.ts @@ -1,6 +1,6 @@ import { RequestHandler, Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; -import { IInviteController } from "@/controllers/inviteController.js"; +import { IInviteController } from "@/api/controllers/inviteController.js"; class InviteRoutes { private router: Router; diff --git a/server/src/routes/logRoutes.ts b/server/src/api/routes/logRoutes.ts old mode 100755 new mode 100644 similarity index 77% rename from server/src/routes/logRoutes.ts rename to server/src/api/routes/logRoutes.ts index ea03d690e..ef98b5189 --- a/server/src/routes/logRoutes.ts +++ b/server/src/api/routes/logRoutes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { isAllowed } from "@/middleware/isAllowed.js"; -import { ILogController } from "@/controllers/logController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { ILogController } from "@/api/controllers/logController.js"; class LogRoutes { private router: Router; private logController: ILogController; diff --git a/server/src/routes/maintenanceWindowRoute.ts b/server/src/api/routes/maintenanceWindowRoute.ts old mode 100755 new mode 100644 similarity index 90% rename from server/src/routes/maintenanceWindowRoute.ts rename to server/src/api/routes/maintenanceWindowRoute.ts index 1c2c139c0..43d03ff34 --- a/server/src/routes/maintenanceWindowRoute.ts +++ b/server/src/api/routes/maintenanceWindowRoute.ts @@ -1,4 +1,4 @@ -import { IMaintenanceWindowController } from "@/controllers/maintenanceWindowController.js"; +import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js"; import { Router } from "express"; class MaintenanceWindowRoutes { diff --git a/server/src/routes/monitorRoute.ts b/server/src/api/routes/monitorRoute.ts old mode 100755 new mode 100644 similarity index 95% rename from server/src/routes/monitorRoute.ts rename to server/src/api/routes/monitorRoute.ts index 0b5e24dd0..29834ce70 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/api/routes/monitorRoute.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { isAllowed } from "@/middleware/isAllowed.js"; -import { IMonitorController } from "@/controllers/monitorController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { IMonitorController } from "@/api/controllers/monitorController.js"; class MonitorRoutes { private router: Router; diff --git a/server/src/routes/notificationRoute.ts b/server/src/api/routes/notificationRoute.ts old mode 100755 new mode 100644 similarity index 91% rename from server/src/routes/notificationRoute.ts rename to server/src/api/routes/notificationRoute.ts index 4ec6ce3b6..16d9a7086 --- a/server/src/routes/notificationRoute.ts +++ b/server/src/api/routes/notificationRoute.ts @@ -1,4 +1,4 @@ -import { INotificationController } from "@/controllers/notificationController.js"; +import { INotificationController } from "@/api/controllers/notificationController.js"; import { Router } from "express"; class NotificationRoutes { private router: Router; diff --git a/server/src/routes/queueRoute.ts b/server/src/api/routes/queueRoute.ts old mode 100755 new mode 100644 similarity index 84% rename from server/src/routes/queueRoute.ts rename to server/src/api/routes/queueRoute.ts index d5d35147c..0a9d41bb1 --- a/server/src/routes/queueRoute.ts +++ b/server/src/api/routes/queueRoute.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { isAllowed } from "@/middleware/isAllowed.js"; -import { IJobQueueController } from "@/controllers/queueController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { IJobQueueController } from "@/api/controllers/queueController.js"; class QueueRoutes { private router: Router; private queueController: IJobQueueController; diff --git a/server/src/routes/settingsRoute.ts b/server/src/api/routes/settingsRoute.ts old mode 100755 new mode 100644 similarity index 90% rename from server/src/routes/settingsRoute.ts rename to server/src/api/routes/settingsRoute.ts index aec2319c2..3bb0e75fd --- a/server/src/routes/settingsRoute.ts +++ b/server/src/api/routes/settingsRoute.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; -import { ISettingsController } from "@/controllers/settingsController.js"; +import { ISettingsController } from "@/api/controllers/settingsController.js"; class SettingsRoutes { private router: Router; diff --git a/server/src/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts old mode 100755 new mode 100644 similarity index 52% rename from server/src/routes/statusPageRoute.ts rename to server/src/api/routes/statusPageRoute.ts index 871a67300..816c7212a --- a/server/src/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,7 +1,7 @@ -import { IStatusPageController } from "@/controllers/statusPageController.js"; +import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; -import multer from "multer"; -const upload = multer(); +import { isAllowed } from "@/api/middleware/isAllowed.js"; +import { imageUpload } from "@/api/middleware/upload.js"; class StatusPageRoutes { private router: Router; @@ -16,11 +16,12 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); + this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); - this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); + this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage); } getRouter() { diff --git a/server/src/routes/tagRoutes.ts b/server/src/api/routes/tagRoutes.ts similarity index 90% rename from server/src/routes/tagRoutes.ts rename to server/src/api/routes/tagRoutes.ts index 9b237c976..c1a3f7972 100644 --- a/server/src/routes/tagRoutes.ts +++ b/server/src/api/routes/tagRoutes.ts @@ -1,4 +1,4 @@ -import { ITagsController } from "@/controllers/tagController.js"; +import { ITagsController } from "@/api/controllers/tagController.js"; import { Router } from "express"; class TagRoutes { private router: Router; diff --git a/server/src/validation/authValidation.ts b/server/src/api/validation/authValidation.ts similarity index 96% rename from server/src/validation/authValidation.ts rename to server/src/api/validation/authValidation.ts index d8f592cee..be30272e6 100644 --- a/server/src/validation/authValidation.ts +++ b/server/src/api/validation/authValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { passwordPattern, nameValidation, lowercaseEmailValidation } from "./shared.js"; -import { UserRoles } from "@/types/user.js"; +import { UserRoles } from "@/domain/users/user.type.js"; //**************************************** // Auth Validations diff --git a/server/src/validation/checkValidation.ts b/server/src/api/validation/checkValidation.ts similarity index 91% rename from server/src/validation/checkValidation.ts rename to server/src/api/validation/checkValidation.ts index 0c58b9df1..db0978357 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/api/validation/checkValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; -import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorTypes } from "@/types/monitor.js"; +import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js"; +import { MonitorTypes } from "@/domain/monitors/monitor.types.js"; //**************************************** // Check Validations diff --git a/server/src/validation/incidentValidation.ts b/server/src/api/validation/incidentValidation.ts similarity index 100% rename from server/src/validation/incidentValidation.ts rename to server/src/api/validation/incidentValidation.ts diff --git a/server/src/validation/index.ts b/server/src/api/validation/index.ts similarity index 94% rename from server/src/validation/index.ts rename to server/src/api/validation/index.ts index 8627d3892..55c586769 100644 --- a/server/src/validation/index.ts +++ b/server/src/api/validation/index.ts @@ -9,7 +9,7 @@ export * from "./shared.js"; // Environment validation -export * from "./envValidation.js"; +export * from "@/config/envValidation.js"; // Domain-specific validations export * from "./authValidation.js"; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/api/validation/maintenanceWindowValidation.ts similarity index 96% rename from server/src/validation/maintenanceWindowValidation.ts rename to server/src/api/validation/maintenanceWindowValidation.ts index 837db28e8..2ee22bc19 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/api/validation/maintenanceWindowValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; -import { DurationUnits } from "@/types/maintenanceWindow.js"; +import { DurationUnits } from "@/domain/maintenance-windows/maintenance-window.type.js"; const dateToString = z.coerce.date().transform((d) => d.toISOString()); diff --git a/server/src/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts similarity index 88% rename from server/src/validation/monitorValidation.ts rename to server/src/api/validation/monitorValidation.ts index d5164b8ef..6dea5b856 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -1,7 +1,17 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; -import { GeoContinents } from "@/types/geoCheck.js"; -import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; +import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js"; +import { + DnsRecordTypes, + HttpMethods, + HttpStatusCodeSet, + MonitorMatchMethods, + MonitorStatuses, + MonitorTypes, + PageSpeedStrategies, +} from "@/domain/monitors/monitor.types.js"; + +const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -61,6 +71,16 @@ const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.R } }; +const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boolean; jsonPath?: string }, ctx: z.RefinementCtx) => { + if (body.method === "HEAD" && (body.useAdvancedMatching === true || (body.jsonPath ?? "") !== "")) { + ctx.addIssue({ + code: "custom", + path: ["method"], + message: "HEAD requests have no response body, so they cannot use advanced matching or a JSON path", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -81,10 +101,12 @@ export const createMonitorBodyValidation = z tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional(), gameId: z.union([z.string(), z.literal("")]).optional(), grpcServiceName: z.union([z.string(), z.literal("")]).default(""), strategy: z.enum(PageSpeedStrategies).optional(), @@ -97,7 +119,8 @@ export const createMonitorBodyValidation = z dnsRecordType: z.enum(DnsRecordTypes).optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const editMonitorBodyValidation = z .object({ @@ -110,12 +133,14 @@ export const editMonitorBodyValidation = z interval: z.number().optional(), notifications: z.array(z.string()).optional(), tags: z.array(z.string()).optional(), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional(), port: z.number().min(1).max(65535).optional(), cpuAlertThreshold: z.number().optional(), memoryAlertThreshold: z.number().optional(), @@ -133,7 +158,8 @@ export const editMonitorBodyValidation = z dnsRecordType: z.enum(DnsRecordTypes).optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -173,6 +199,7 @@ const importedMonitorSchema = z jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + method: z.enum(HttpMethods).optional().default("GET"), url: z.string().min(1, "URL is required"), port: z.number().optional(), isActive: z.boolean().default(true), @@ -180,6 +207,7 @@ const importedMonitorSchema = z uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), tags: z.array(z.string()).default([]), + customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), @@ -203,7 +231,8 @@ const importedMonitorSchema = z updatedAt: z.string().optional(), }) .superRefine(refineDnsHostname) - .superRefine(refineStrategyType); + .superRefine(refineStrategyType) + .superRefine(refineHeadMatching); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -239,8 +268,10 @@ export const monitorResponseSchema = z jsonPath: z.string().optional(), expectedValue: z.string().optional(), matchMethod: z.enum(MonitorMatchMethods).optional(), + method: z.enum(HttpMethods), notifications: z.array(z.string()), tags: z.array(z.string()), + customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), @@ -259,5 +290,6 @@ export const monitorResponseSchema = z userId: z.string(), createdAt: z.string(), updatedAt: z.string(), + lastEvaluatedAt: z.number(), }) .passthrough(); diff --git a/server/src/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts similarity index 100% rename from server/src/validation/notificationValidation.ts rename to server/src/api/validation/notificationValidation.ts diff --git a/server/src/validation/queueValidation.ts b/server/src/api/validation/queueValidation.ts similarity index 100% rename from server/src/validation/queueValidation.ts rename to server/src/api/validation/queueValidation.ts diff --git a/server/src/validation/settingsValidation.ts b/server/src/api/validation/settingsValidation.ts similarity index 95% rename from server/src/validation/settingsValidation.ts rename to server/src/api/validation/settingsValidation.ts index db58c50ed..d118cbd4f 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/api/validation/settingsValidation.ts @@ -1,4 +1,4 @@ -import { CHECK_TTL_SENTINEL } from "@/types/check.js"; +import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js"; import { z } from "zod"; //**************************************** diff --git a/server/src/validation/shared.ts b/server/src/api/validation/shared.ts similarity index 97% rename from server/src/validation/shared.ts rename to server/src/api/validation/shared.ts index a3e17866b..18b8ba463 100644 --- a/server/src/validation/shared.ts +++ b/server/src/api/validation/shared.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { type UserRole } from "@/types/user.js"; +import { type UserRole } from "@/domain/users/user.type.js"; export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; diff --git a/server/src/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts similarity index 70% rename from server/src/validation/statusPageValidation.ts rename to server/src/api/validation/statusPageValidation.ts index 6a42d340c..a650129cd 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js"; +import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; +import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; +import { cssReferencesExternalResource } from "@/utils/customCss.js"; //**************************************** // Status Page Validations @@ -15,6 +17,20 @@ export const getStatusPageQueryValidation = z.object({ timeFrame: z.coerce.number().optional(), }); +export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ + domain: z.string().optional(), +}); + +const customDomainValidation = z.preprocess( + (val) => { + if (val === undefined || val === null) { + return undefined; + } + return normalizeStatusPageDomain(String(val)); + }, + z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional() +); + export const createStatusPageBodyValidation = z .object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), @@ -22,6 +38,7 @@ export const createStatusPageBodyValidation = z url: z.string().regex(/^[a-zA-Z0-9_-]+$/, { message: "URL can only contain letters, numbers, underscores, and hyphens", }), + customDomain: customDomainValidation, timezone: z.string().optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), @@ -32,6 +49,13 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/src/validation/tagValidation.ts b/server/src/api/validation/tagValidation.ts similarity index 100% rename from server/src/validation/tagValidation.ts rename to server/src/api/validation/tagValidation.ts diff --git a/server/src/validation/userValidation.ts b/server/src/api/validation/userValidation.ts similarity index 97% rename from server/src/validation/userValidation.ts rename to server/src/api/validation/userValidation.ts index 40e7d8b54..21fb55317 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/api/validation/userValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { UserRoles } from "@/types/user.js"; +import { UserRoles } from "@/domain/users/user.type.js"; import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js"; //**************************************** diff --git a/server/src/app.ts b/server/src/app.ts index da1cd8ad9..2ed1c2f01 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,26 +1,29 @@ import express from "express"; import path from "path"; -import cors from "cors"; +import cors, { type CorsOptions } from "cors"; import helmet from "helmet"; import compression from "compression"; import cookieParser from "cookie-parser"; import swaggerUi, { type JsonObject } from "swagger-ui-express"; -import { handleErrors } from "@/middleware/handleErrors.js"; -import { generalApiLimiter } from "@/middleware/rateLimiter.js"; -import { sanitizeBody, sanitizeQuery } from "@/middleware/sanitization.js"; +import { handleErrors } from "@/api/middleware/handleErrors.js"; +import { generalApiLimiter } from "@/api/middleware/rateLimiter.js"; +import { sanitizeBody, sanitizeQuery } from "@/api/middleware/sanitization.js"; import { setupRoutes } from "@/config/routes.js"; -import { InitializedServices } from "@/config/services.js"; import { InitializedControllers } from "@/config/controllers.js"; -import { EnvConfig } from "@/service/system/settingsService.js"; +import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; +import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; +import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; +import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; +import { ApiServices } from "@/config/services.api.js"; export const createApp = ({ - services, + apiServices: services, controllers, envSettings, frontendPath, openApiSpec, }: { - services: InitializedServices; + apiServices: ApiServices; controllers: InitializedControllers; envSettings: EnvConfig; frontendPath: string; @@ -28,17 +31,26 @@ export const createApp = ({ }) => { const allowedOrigin = envSettings.clientHost; const app = express(); + const defaultCorsOptions: CorsOptions = { + origin: allowedOrigin, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], + credentials: true, + }; + const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository); - app.use(generalApiLimiter); + const devMode = envSettings.nodeEnv === "development"; + app.use(generalApiLimiter(devMode)); - app.use( - cors({ - origin: allowedOrigin, - methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"], - credentials: true, - }) - ); + app.use((req, res, next) => { + const corsOptions = isPublicStatusPageApiPath(req.method, req.path) + ? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin } + : defaultCorsOptions; + + return cors(corsOptions)(req, res, next); + }); + + app.use(createStatusPageDocumentCsp(allowedOrigin)); app.use(express.static(frontendPath)); @@ -104,6 +116,6 @@ export const createApp = ({ app.get("*", (req, res) => { res.sendFile(path.join(frontendPath, "index.html")); }); - app.use(handleErrors); + app.use(handleErrors(services.logger)); return app; }; diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index e757cfa22..7646b41db 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -1,18 +1,18 @@ -import MonitorController from "../controllers/monitorController.js"; -import AuthController from "../controllers/authController.js"; -import SettingsController from "../controllers/settingsController.js"; -import CheckController from "../controllers/checkController.js"; -import GeoCheckController from "../controllers/geoCheckController.js"; -import InviteController from "../controllers/inviteController.js"; -import MaintenanceWindowController from "../controllers/maintenanceWindowController.js"; -import QueueController from "../controllers/queueController.js"; -import LogController from "../controllers/logController.js"; -import StatusPageController from "../controllers/statusPageController.js"; -import NotificationController from "../controllers/notificationController.js"; -import TagsController from "../controllers/tagController.js"; -import DiagnosticController from "../controllers/diagnosticController.js"; -import IncidentController from "../controllers/incidentController.js"; -import type { InitializedServices } from "@/config/services.js"; +import MonitorController from "../api/controllers/monitorController.js"; +import AuthController from "../api/controllers/authController.js"; +import SettingsController from "../api/controllers/settingsController.js"; +import CheckController from "../api/controllers/checkController.js"; +import GeoCheckController from "../api/controllers/geoCheckController.js"; +import InviteController from "../api/controllers/inviteController.js"; +import MaintenanceWindowController from "../api/controllers/maintenanceWindowController.js"; +import QueueController from "../api/controllers/queueController.js"; +import LogController from "../api/controllers/logController.js"; +import StatusPageController from "../api/controllers/statusPageController.js"; +import NotificationController from "../api/controllers/notificationController.js"; +import TagsController from "../api/controllers/tagController.js"; +import DiagnosticController from "../api/controllers/diagnosticController.js"; +import IncidentController from "../api/controllers/incidentController.js"; +import { ApiServices } from "@/config/services.api.js"; export interface InitializedControllers { authController: AuthController; @@ -30,21 +30,21 @@ export interface InitializedControllers { diagnosticController: DiagnosticController; incidentController: IncidentController; } -export const initializeControllers = (services: InitializedServices): InitializedControllers => { +export const initializeControllers = (apiServices: ApiServices): InitializedControllers => { return { - authController: new AuthController(services.userService), - monitorController: new MonitorController(services.monitorService, services.notificationsService), - settingsController: new SettingsController(services.settingsService, services.emailService), - checkController: new CheckController(services.checkService), - geoCheckController: new GeoCheckController(services.geoChecksService), - inviteController: new InviteController(services.inviteService), - maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService), - queueController: new QueueController(services.jobQueue), - logController: new LogController(services.logger), - statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService), - notificationController: new NotificationController(services.notificationsService, services.monitorsRepository), - tagController: new TagsController(services.tagsService), - diagnosticController: new DiagnosticController(services.diagnosticService), - incidentController: new IncidentController(services.incidentService), + authController: new AuthController(apiServices.userService), + monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService), + settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService), + checkController: new CheckController(apiServices.checkService), + geoCheckController: new GeoCheckController(apiServices.geoChecksService), + inviteController: new InviteController(apiServices.inviteService), + maintenanceWindowController: new MaintenanceWindowController(apiServices.maintenanceWindowService), + queueController: new QueueController(apiServices.worker), + logController: new LogController(apiServices.logger), + statusPageController: new StatusPageController(apiServices.statusPageService, apiServices.monitorsRepository, apiServices.settingsService), + notificationController: new NotificationController(apiServices.notificationsService, apiServices.monitorsRepository), + tagController: new TagsController(apiServices.tagsService), + diagnosticController: new DiagnosticController(apiServices.diagnosticService), + incidentController: new IncidentController(apiServices.incidentService), }; }; diff --git a/server/src/validation/envValidation.ts b/server/src/config/envValidation.ts similarity index 56% rename from server/src/validation/envValidation.ts rename to server/src/config/envValidation.ts index 5280a8036..05471e690 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/config/envValidation.ts @@ -1,10 +1,13 @@ import { z } from "zod"; -import { DbTypes, QueueTypes, QueueModes } from "@/types/settings.js"; -import { booleanCoercion } from "./shared.js"; +import { DbTypes, QueueModes } from "@/domain/app-settings/app-settings.type.js"; +import { booleanCoercion } from "@/api/validation/shared.js"; +import { ILogger } from "@/utils/logger.js"; const envSchema = z.object({ // Server Configuration PORT: z.string().default("52345"), + // Port for worker health checks + HEALTH_PORT: z.string().default("52346"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("debug"), @@ -12,8 +15,8 @@ const envSchema = z.object({ DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), DB_TYPE: z.enum(DbTypes).default("mongodb"), - QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"), QUEUE_MODE: z.enum(QueueModes).default("primary"), + QUEUE_PRIMARY_PROCESSES: booleanCoercion.default(true), // JWT Authentication JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), @@ -31,24 +34,39 @@ const envSchema = z.object({ export type ValidatedEnv = z.infer; -export const validateEnv = (): ValidatedEnv => { +export const validateEnv = (logger: ILogger): ValidatedEnv => { const result = envSchema.safeParse(process.env); if (!result.success) { - console.error("Environment validation failed:"); - console.error(""); + logger.error({ + message: "Env validation failed", + method: "validateEnv", + service: "Server", + }); const errors = result.error.format(); for (const [key, value] of Object.entries(errors)) { if (key !== "_errors" && value && typeof value === "object" && "_errors" in value) { - console.error(` ${key}: ${value._errors.join(", ")}`); + logger.error({ + message: `${key}: ${value._errors.join(", ")}`, + method: "validateEnv", + service: "Server", + }); } } - console.error("Please check your .env file and ensure all required variables are set."); + logger.error({ + message: "Please check your .env file and ensure all required variables are set.", + method: "validateEnv", + service: "Server", + }); process.exit(1); } - console.log("Environment variables validated successfully"); + logger.info({ + message: "Environment variables validated successfully", + method: "validateEnv", + service: "Server", + }); return result.data; }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index cff5e6b1d..855c18abb 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -1,28 +1,28 @@ import type { Application } from "express"; -import { createVerifyJWT } from "../middleware/verifyJWT.js"; -import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js"; -import { authApiLimiter } from "../middleware/rateLimiter.js"; +import { createVerifyJWT } from "../api/middleware/verifyJWT.js"; +import { createVerifyStatusPageAccess } from "../api/middleware/verifyStatusPageAccess.js"; +import { authApiLimiter } from "../api/middleware/rateLimiter.js"; import type { InitializedControllers } from "./controllers.js"; -import type { InitializedServices } from "./services.js"; +import type { ApiServices } from "@/config/services.api.js"; -import AuthRoutes from "../routes/authRoute.js"; -import InviteRoutes from "../routes/inviteRoute.js"; -import MonitorRoutes from "../routes/monitorRoute.js"; -import CheckRoutes from "../routes/checkRoute.js"; -import GeoCheckRoutes from "../routes/geoCheckRoutes.js"; -import SettingsRoutes from "../routes/settingsRoute.js"; -import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js"; -import StatusPageRoutes from "../routes/statusPageRoute.js"; -import QueueRoutes from "../routes/queueRoute.js"; -import LogRoutes from "../routes/logRoutes.js"; -import DiagnosticRoutes from "../routes/diagnosticRoute.js"; -import NotificationRoutes from "../routes/notificationRoute.js"; -import TagRoutes from "../routes/tagRoutes.js"; +import AuthRoutes from "../api/routes/authRoute.js"; +import InviteRoutes from "../api/routes/inviteRoute.js"; +import MonitorRoutes from "../api/routes/monitorRoute.js"; +import CheckRoutes from "../api/routes/checkRoute.js"; +import GeoCheckRoutes from "../api/routes/geoCheckRoutes.js"; +import SettingsRoutes from "../api/routes/settingsRoute.js"; +import MaintenanceWindowRoutes from "../api/routes/maintenanceWindowRoute.js"; +import StatusPageRoutes from "../api/routes/statusPageRoute.js"; +import QueueRoutes from "../api/routes/queueRoute.js"; +import LogRoutes from "../api/routes/logRoutes.js"; +import DiagnosticRoutes from "../api/routes/diagnosticRoute.js"; +import NotificationRoutes from "../api/routes/notificationRoute.js"; +import TagRoutes from "../api/routes/tagRoutes.js"; -import IncidentRoutes from "../routes/incidentRoute.js"; +import IncidentRoutes from "../api/routes/incidentRoute.js"; -export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => { - const verifyJWT = createVerifyJWT(services.settingsService); +export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => { + const verifyJWT = createVerifyJWT(apiServices.settingsService); const authRoutes = new AuthRoutes(controllers.authController, verifyJWT); const monitorRoutes = new MonitorRoutes(controllers.monitorController); const settingsRoutes = new SettingsRoutes(controllers.settingsController); @@ -32,7 +32,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController); const queueRoutes = new QueueRoutes(controllers.queueController); const logRoutes = new LogRoutes(controllers.logController); - const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT); + const verifyStatusPageAccess = createVerifyStatusPageAccess(apiServices.statusPagesRepository, verifyJWT); const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); const tagRoutes = new TagRoutes(controllers.tagController); diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts new file mode 100644 index 000000000..0b9361e5d --- /dev/null +++ b/server/src/config/services.api.ts @@ -0,0 +1,109 @@ +import { DiagnosticService, IDiagnosticService } from "@/domain/diagnostics/diagnostic.service.js"; +import { IInviteService, InviteService } from "@/domain/invites/invite.service.js"; +import { IMaintenanceWindowService, MaintenanceWindowService } from "@/domain/maintenance-windows/maintenance-window.service.js"; +import { IMonitorService, MonitorService } from "@/domain/monitors/monitor.service.js"; +import { IStatusPageService, StatusPageService } from "@/domain/status-pages/status-page.service.js"; +import { ITagsService, TagsService } from "@/domain/tags/tag.service.js"; +import { IUserService, UserService } from "@/domain/users/user.service.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; + +// Third-party +import jwt from "jsonwebtoken"; +import crypto from "crypto"; +import { games } from "gamedig"; + +import { SharedServices } from "@/config/services.shared.js"; + +export interface ApiServices extends SharedServices { + worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only) + userService: IUserService; + monitorService: IMonitorService; + maintenanceWindowService: IMaintenanceWindowService; + inviteService: IInviteService; + statusPageService: IStatusPageService; + tagsService: ITagsService; + diagnosticService: IDiagnosticService; +} + +export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => { + const { + logger, + db, + settingsService, + emailService, + monitorsRepository, + checksRepository, + geoChecksRepository, + monitorStatsRepository, + statusPagesRepository, + usersRepository, + invitesRepository, + recoveryTokensRepository, + settingsRepository, + tagsRepository, + incidentsRepository, + teamsRepository, + maintenanceWindowsRepository, + jobsRepository, + } = shared; + + const userService = new UserService({ + crypto, + emailService, + settingsService, + logger, + jwt, + scheduler: jobScheduler, + monitorsRepository, + usersRepository, + invitesRepository, + recoveryTokensRepository, + settingsRepository, + teamsRepository, + }); + + // *********************** + // Business services + // *********************** + + const monitorService = new MonitorService({ + scheduler: jobScheduler, + logger, + games, + monitorsRepository, + checksRepository, + geoChecksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + }); + + const maintenanceWindowService = new MaintenanceWindowService({ + monitorsRepository, + maintenanceWindowsRepository, + jobsRepository, + scheduler: jobScheduler, + }); + + const inviteService = new InviteService({ + invitesRepository, + settingsService, + emailService, + }); + + const statusPageService = new StatusPageService(statusPagesRepository, settingsService); + const tagsService = new TagsService(tagsRepository, monitorsRepository); + const diagnosticService = new DiagnosticService(db); + + return { + ...shared, + worker: jobScheduler, + userService, + monitorService, + maintenanceWindowService, + inviteService, + statusPageService, + tagsService, + diagnosticService, + }; +}; diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts new file mode 100644 index 000000000..49b73f52e --- /dev/null +++ b/server/src/config/services.shared.ts @@ -0,0 +1,218 @@ +import { Mongoose } from "mongoose"; +import { hostname } from "node:os"; +import { randomUUID } from "node:crypto"; +import fs from "fs"; +import path from "path"; +import nodemailer from "nodemailer"; +import pkg from "handlebars"; +const { compile } = pkg; +import mjml2html from "mjml"; + +import MongoDB from "@/db/db.mongo.js"; +import { IDb } from "@/db/db.interface.js"; +import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { ICheckService, CheckService } from "@/domain/checks/check.service.js"; +import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; +import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js"; +import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; +import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js"; +import { IEmailService, EmailService } from "@/service/emailService.js"; +import { GlobalPingService } from "@/service/globalPingService.js"; +import { ILogger } from "@/utils/logger.js"; + +// Notification providers +import { WebhookProvider } from "@/domain/notifications/providers/webhook.js"; +import { SlackProvider } from "@/domain/notifications/providers/slack.js"; +import { EmailProvider } from "@/domain/notifications/providers/email.js"; +import { DiscordProvider } from "@/domain/notifications/providers/discord.js"; +import { PagerDutyProvider } from "@/domain/notifications/providers/pagerduty.js"; +import { MatrixProvider } from "@/domain/notifications/providers/matrix.js"; +import { TeamsProvider } from "@/domain/notifications/providers/teams.js"; +import { TelegramProvider } from "@/domain/notifications/providers/telegram.js"; +import { PushoverProvider } from "@/domain/notifications/providers/pushover.js"; +import { TwilioProvider } from "@/domain/notifications/providers/twilio.js"; +import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js"; + +// Repository interfaces +import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; +import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js"; +import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; +import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js"; +import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; +import { IRecoveryTokensRepository } from "@/domain/recovery-tokens/recovery-token.repository.interface.js"; +import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; +import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; +import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; + +// Mongo repository implementations +import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; +import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js"; +import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js"; +import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js"; +import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js"; +import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js"; +import MongoMaintenanceWindowsRepository from "@/domain/maintenance-windows/maintenance-window.repository.mongo.js"; +import MongoMonitorStatsRepository from "@/domain/monitor-stats/monitor-stats.repository.mongo.js"; +import MongoMonitorsRepository from "@/domain/monitors/monitor.repository.mongo.js"; +import MongoNotificationsRepository from "@/domain/notifications/notification.repository.mongo.js"; +import MongoQueueWorkersRepository from "@/domain/queue-workers/queue-worker.repository.mongo.js"; +import MongoRecoveryTokensRepository from "@/domain/recovery-tokens/recovery-token.repository.mongo.js"; +import MongoStatusPagesRepository from "@/domain/status-pages/status-page-repository.mongo.js"; +import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; +import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; +import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; + +// Shared infrastructure + business services that both the API and the worker process construct. +export interface SharedServices { + logger: ILogger; + db: IDb; + settingsService: ISettingsService; + emailService: IEmailService; + notificationMessageBuilder: INotificationMessageBuilder; + incidentService: IIncidentService; + checkService: ICheckService; + geoChecksService: IGeoChecksService; + notificationsService: INotificationsService; + + // Queue identity (one per process; the worker reuses these) + workerId: string; + jobsRepository: IJobsRepository; + queueWorkersRepository: IQueueWorkersRepository; + + // Repositories + monitorsRepository: IMonitorsRepository; + checksRepository: IChecksRepository; + geoChecksRepository: IGeoChecksRepository; + monitorStatsRepository: IMonitorStatsRepository; + statusPagesRepository: IStatusPagesRepository; + usersRepository: IUsersRepository; + invitesRepository: IInvitesRepository; + recoveryTokensRepository: IRecoveryTokensRepository; + settingsRepository: ISettingsRepository; + notificationsRepository: INotificationsRepository; + tagsRepository: ITagsRepository; + incidentsRepository: IIncidentsRepository; + teamsRepository: ITeamsRepository; + maintenanceWindowsRepository: IMaintenanceWindowsRepository; +} + +export const buildShared = async ({ + logger, + envSettings, + settingsService, +}: { + logger: ILogger; + envSettings: EnvConfig; + settingsService: ISettingsService; +}): Promise => { + // Shared worker ID + const workerId = `${hostname()}:${process.pid}:${randomUUID()}`; + + // Create DB + let db: IDb | null = null; + db = new MongoDB(logger, envSettings); + await db.connect(); + + // Repositories + const monitorsRepository = new MongoMonitorsRepository(); + const checksRepository = new MongoChecksRepository(logger); + const incidentsRepository = new MongoIncidentsRepository(); + const usersRepository = new MongoUsersRepository(); + const geoChecksRepository = new MongoGeoChecksRepository(logger); + const notificationsRepository = new MongoNotificationsRepository(); + const jobsRepository = new MongoJobsRepository(workerId); + const queueWorkersRepository = new MongoQueueWorkersRepository(); + const monitorStatsRepository = new MongoMonitorStatsRepository(); + const statusPagesRepository = new MongoStatusPagesRepository(); + const invitesRepository = new MongoInvitesRepository(); + const recoveryTokensRepository = new MongoRecoveryTokensRepository(); + const settingsRepository = new MongoSettingsRepository(); + const tagsRepository = new MongoTagsRepository(); + const teamsRepository = new MongoTeamsRepository(); + const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + + // Inject settings repository into settings service (now that DB is connected) + (settingsService as SettingsService).setRepository(settingsRepository); + + // Services + const notificationMessageBuilder = new NotificationMessageBuilder(); + const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); + const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); + const checkService = new CheckService(monitorsRepository, logger, checksRepository); + + const globalPingService = new GlobalPingService(logger); + const geoChecksService = new GeoChecksService({ + logger, + geoChecksRepository, + globalPingService, + monitorsRepository, + }); + + const webhookProvider = new WebhookProvider(logger); + const slackProvider = new SlackProvider(logger); + const emailProvider = new EmailProvider(emailService, logger); + const discordProvider = new DiscordProvider(logger); + const pagerDutyProvider = new PagerDutyProvider(logger); + const matrixProvider = new MatrixProvider(logger); + const teamsProvider = new TeamsProvider(logger); + const telegramProvider = new TelegramProvider(logger); + const pushoverProvider = new PushoverProvider(logger); + const twilioProvider = new TwilioProvider(logger); + const ntfyProvider = new NtfyProvider(logger); + + const notificationsService = new NotificationsService( + notificationsRepository, + monitorsRepository, + webhookProvider, + emailProvider, + slackProvider, + discordProvider, + pagerDutyProvider, + matrixProvider, + teamsProvider, + telegramProvider, + pushoverProvider, + twilioProvider, + ntfyProvider, + settingsService, + logger, + notificationMessageBuilder + ); + + const sharedServices: SharedServices = { + logger, + db, + settingsService, + emailService, + notificationMessageBuilder, + incidentService, + checkService, + geoChecksService, + notificationsService, + workerId, + jobsRepository, + monitorsRepository, + queueWorkersRepository, + checksRepository, + geoChecksRepository, + monitorStatsRepository, + statusPagesRepository, + usersRepository, + invitesRepository, + recoveryTokensRepository, + settingsRepository, + notificationsRepository, + tagsRepository, + incidentsRepository, + teamsRepository, + maintenanceWindowsRepository, + }; + return sharedServices; +}; diff --git a/server/src/config/services.ts b/server/src/config/services.ts deleted file mode 100644 index 818ef1663..000000000 --- a/server/src/config/services.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { Mongoose } from "mongoose"; -import MongoDB from "../db/MongoDB.js"; -import { IDb } from "@/db/IDb.js"; -import { - // Service classes - NetworkService, - EmailService, - BufferService, - GlobalPingService, - LessSimpleQueue, - SuperSimpleQueue, - QueueHelper, - NotificationsService, - TagsService, - StatusService, - NotificationMessageBuilder, - MonitorService, - StatusPageService, - UserService, - CheckService, - GeoChecksService, - DiagnosticService, - InviteService, - MaintenanceWindowService, - IncidentService, - // Notification providers - WebhookProvider, - SlackProvider, - EmailProvider, - DiscordProvider, - PagerDutyProvider, - MatrixProvider, - TeamsProvider, - TelegramProvider, - PushoverProvider, - TwilioProvider, - NtfyProvider, - // Interfaces - INetworkService, - IEmailService, - IBufferService, - IJobQueue, - INotificationsService, - ITagsService, - IStatusService, - IMonitorService, - IUserService, - ICheckService, - IGeoChecksService, - IDiagnosticService, - IInviteService, - IMaintenanceWindowService, - IStatusPageService, - IIncidentService, - INotificationMessageBuilder, - ISettingsService, - SettingsService, - EnvConfig, -} from "@/service/index.js"; - -// Network providers -import { PingProvider } from "@/service/infrastructure/network/PingProvider.js"; -import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; -import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; -import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js"; -import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js"; -import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js"; -import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; -import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; -import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; -import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js"; -import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js"; - -// Third-party -import { Resolver } from "dns/promises"; -import axios from "axios"; -import got from "got"; -import ping from "ping"; -import Docker from "dockerode"; -import net from "net"; -import fs from "fs"; -import path from "path"; -import nodemailer from "nodemailer"; -import pkg from "handlebars"; -const { compile } = pkg; -import mjml2html from "mjml"; -import jwt from "jsonwebtoken"; -import crypto from "crypto"; -import { games, GameDig } from "gamedig"; -import jmespath from "jmespath"; -import * as grpc from "@grpc/grpc-js"; -import * as protoLoader from "@grpc/proto-loader"; -import WebSocket from "ws"; - -// Repositories -import { - MongoMonitorsRepository, - MongoChecksRepository, - MongoGeoChecksRepository, - MongoMonitorStatsRepository, - MongoStatusPagesRepository, - MongoUsersRepository, - MongoInvitesRepository, - MongoRecoveryTokensRepository, - MongoNotificationsRepository, - MongoTagsRepository, - MongoIncidentsRepository, - MongoTeamsRepository, - MongoMaintenanceWindowsRepository, - MongoSettingsRepository, - MongoQueueWorkersRepository, - IMonitorsRepository, - IChecksRepository, - IGeoChecksRepository, - IMonitorStatsRepository, - IStatusPagesRepository, - IUsersRepository, - IInvitesRepository, - IRecoveryTokensRepository, - ISettingsRepository, - INotificationsRepository, - ITagsRepository, - IIncidentsRepository, - ITeamsRepository, - IMaintenanceWindowsRepository, -} from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import { AppError } from "@/utils/AppError.js"; -import { type QueueMode } from "@/types/settings.js"; - -export type InitializedServices = { - settingsService: ISettingsService; - db: IDb; - networkService: INetworkService; - emailService: IEmailService; - bufferService: IBufferService; - statusService: IStatusService; - jobQueue: IJobQueue; - userService: IUserService; - checkService: ICheckService; - geoChecksService: IGeoChecksService; - diagnosticService: IDiagnosticService; - inviteService: IInviteService; - maintenanceWindowService: IMaintenanceWindowService; - monitorService: IMonitorService; - incidentService: IIncidentService; - logger: ILogger; - notificationsService: INotificationsService; - tagsService: ITagsService; - statusPageService: IStatusPageService; - notificationMessageBuilder: INotificationMessageBuilder; - - // Repositories - monitorsRepository: IMonitorsRepository; - checksRepository: IChecksRepository; - geoChecksRepository: IGeoChecksRepository; - monitorStatsRepository: IMonitorStatsRepository; - statusPagesRepository: IStatusPagesRepository; - usersRepository: IUsersRepository; - invitesRepository: IInvitesRepository; - recoveryTokensRepository: IRecoveryTokensRepository; - settingsRepository: ISettingsRepository; - notificationsRepository: INotificationsRepository; - tagsRepository: ITagsRepository; - incidentsRepository: IIncidentsRepository; - teamsRepository: ITeamsRepository; - maintenanceWindowsRepository: IMaintenanceWindowsRepository; -}; - -export const initializeServices = async ({ - logger, - envSettings, - settingsService, - queueMode = "primary", -}: { - logger: ILogger; - envSettings: EnvConfig; - settingsService: ISettingsService; - queueMode?: QueueMode; -}): Promise => { - // Create DB - - let db: IDb | null = null; - db = new MongoDB(logger, envSettings); - await db.connect(); - - // ** NOTE ** - // const dbType = envSettings.dbType; - // DB type has been fixed to MongoDB for now - - // if (dbType === "mongodb") { - // db = new MongoDB(logger, envSettings); - // } - - // if (!db) { - // throw new AppError({ message: "Unsupported database type", status: 500 }); - // } - - // await db.connect(); - - // Repositories - - // ** NOTE ** - // DB Type fixed to MongoDB for now - // let monitorsRepository: IMonitorsRepository; - // let checksRepository: IChecksRepository; - // let geoChecksRepository: IGeoChecksRepository; - // let monitorStatsRepository: IMonitorStatsRepository; - // let statusPagesRepository: IStatusPagesRepository; - // let usersRepository: IUsersRepository; - // let invitesRepository: IInvitesRepository; - // let recoveryTokensRepository: IRecoveryTokensRepository; - // let settingsRepository: ISettingsRepository; - // let notificationsRepository: INotificationsRepository; - // let tagsRepository: ITagsRepository; - // let incidentsRepository: IIncidentsRepository; - // let teamsRepository: ITeamsRepository; - // let maintenanceWindowsRepository: IMaintenanceWindowsRepository; - - // if (dbType === "mongodb") { - // monitorsRepository = new MongoMonitorsRepository(); - // checksRepository = new MongoChecksRepository(logger); - // geoChecksRepository = new MongoGeoChecksRepository(logger); - // monitorStatsRepository = new MongoMonitorStatsRepository(); - // statusPagesRepository = new MongoStatusPagesRepository(); - // usersRepository = new MongoUsersRepository(); - // invitesRepository = new MongoInvitesRepository(); - // recoveryTokensRepository = new MongoRecoveryTokensRepository(); - // settingsRepository = new MongoSettingsRepository(); - // notificationsRepository = new MongoNotificationsRepository(); - // tagsRepository = new MongoTagsRepository(); - // incidentsRepository = new MongoIncidentsRepository(); - // teamsRepository = new MongoTeamsRepository(); - // maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - // } else { - // throw new AppError({ message: "Unsupported database type", status: 500 }); - // } - - const monitorsRepository = new MongoMonitorsRepository(); - const checksRepository = new MongoChecksRepository(logger); - const geoChecksRepository = new MongoGeoChecksRepository(logger); - const monitorStatsRepository = new MongoMonitorStatsRepository(); - const statusPagesRepository = new MongoStatusPagesRepository(); - const usersRepository = new MongoUsersRepository(); - const invitesRepository = new MongoInvitesRepository(); - const recoveryTokensRepository = new MongoRecoveryTokensRepository(); - const settingsRepository = new MongoSettingsRepository(); - const notificationsRepository = new MongoNotificationsRepository(); - const tagsRepository = new MongoTagsRepository(); - const incidentsRepository = new MongoIncidentsRepository(); - const teamsRepository = new MongoTeamsRepository(); - const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - const queueWorkersRepository = new MongoQueueWorkersRepository(); - - // Inject settings repository into settings service (now that DB is connected) - (settingsService as SettingsService).setRepository(settingsRepository); - - // Network providers - const pingProvider = new PingProvider(ping); - const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); - const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); - const hardwareProvider = new HardwareProvider(httpProvider); - const dockerProvider = new DockerProvider(logger, Docker); - const portProvider = new PortProvider(net); - const gameProvider = new GameProvider(logger, GameDig); - const grpcProvider = new GrpcProvider(grpc, protoLoader); - const webSocketProvider = new WebSocketProvider(WebSocket); - const dnsProvider = new DNSProvider(() => new Resolver()); - - const networkService = new NetworkService(axios, logger, [ - pingProvider, - httpProvider, - pageSpeedProvider, - hardwareProvider, - dockerProvider, - portProvider, - gameProvider, - grpcProvider, - webSocketProvider, - dnsProvider, - ]); - const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); - - const notificationMessageBuilder = new NotificationMessageBuilder(); - - const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); - - const checkService = new CheckService(monitorsRepository, logger, checksRepository); - - const globalPingService = new GlobalPingService(logger); - - const geoChecksService = new GeoChecksService({ - logger, - geoChecksRepository, - globalPingService, - monitorsRepository, - }); - - const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService); - - const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository); - - // Notification providers - const webhookProvider = new WebhookProvider(logger); - const slackProvider = new SlackProvider(logger); - const emailProvider = new EmailProvider(emailService, logger); - const discordProvider = new DiscordProvider(logger); - const pagerDutyProvider = new PagerDutyProvider(logger); - const matrixProvider = new MatrixProvider(logger); - const teamsProvider = new TeamsProvider(logger); - const telegramProvider = new TelegramProvider(logger); - const pushoverProvider = new PushoverProvider(logger); - const twilioProvider = new TwilioProvider(logger); - const ntfyProvider = new NtfyProvider(logger); - - const notificationsService = new NotificationsService( - notificationsRepository, - monitorsRepository, - webhookProvider, - emailProvider, - slackProvider, - discordProvider, - pagerDutyProvider, - matrixProvider, - teamsProvider, - telegramProvider, - pushoverProvider, - twilioProvider, - ntfyProvider, - settingsService, - logger, - notificationMessageBuilder - ); - - const tagsService = new TagsService(tagsRepository, monitorsRepository); - - const queueHelper = new QueueHelper( - logger, - networkService, - statusService, - notificationsService, - checkService, - settingsService, - bufferService, - incidentService, - maintenanceWindowsRepository, - monitorsRepository, - teamsRepository, - monitorStatsRepository, - checksRepository, - incidentsRepository, - geoChecksService, - geoChecksRepository - ); - - if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") { - throw new AppError({ - message: `Worker mode requires QUEUE_TYPE="lessSimpleQueue" got ${envSettings.queueType}`, - status: 500, - service: "config services", - method: "initializeServices", - }); - } - - let jobQueue: IJobQueue; - switch (envSettings.queueType) { - case "lessSimpleQueue": - jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode); - break; - default: - jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository); - } - - // Business services - const userService = new UserService({ - crypto, - emailService, - settingsService, - logger, - jwt, - jobQueue, - monitorsRepository, - usersRepository, - invitesRepository, - recoveryTokensRepository, - settingsRepository, - teamsRepository, - }); - - const diagnosticService = new DiagnosticService(db); - const inviteService = new InviteService({ - invitesRepository, - settingsService, - emailService, - }); - const maintenanceWindowService = new MaintenanceWindowService({ - monitorsRepository, - maintenanceWindowsRepository, - }); - const monitorService = new MonitorService({ - jobQueue, - logger, - games, - monitorsRepository, - checksRepository, - geoChecksRepository, - monitorStatsRepository, - statusPagesRepository, - incidentsRepository, - }); - - const statusPageService = new StatusPageService(statusPagesRepository, settingsService); - - const services = { - settingsService, - db, - networkService, - emailService, - bufferService, - statusService, - jobQueue, - userService, - checkService, - geoChecksService, - diagnosticService, - inviteService, - maintenanceWindowService, - monitorService, - incidentService, - logger, - notificationsService, - tagsService, - statusPageService, - notificationMessageBuilder, - - // Repositories - monitorsRepository, - checksRepository, - geoChecksRepository, - monitorStatsRepository, - statusPagesRepository, - usersRepository, - invitesRepository, - recoveryTokensRepository, - settingsRepository, - notificationsRepository, - tagsRepository, - incidentsRepository, - teamsRepository, - maintenanceWindowsRepository, - }; - - return services; -}; diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts new file mode 100644 index 000000000..44997af5f --- /dev/null +++ b/server/src/config/services.worker.ts @@ -0,0 +1,154 @@ +import { Resolver } from "dns/promises"; +import axios from "axios"; +import got from "got"; +import ping from "ping"; +import Docker from "dockerode"; +import net from "net"; +import { GameDig } from "gamedig"; +import jmespath from "jmespath"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import WebSocket from "ws"; +import mongoose from "mongoose"; + +import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; +import { SharedServices } from "@/config/services.shared.js"; +import { INetworkService, NetworkService } from "@/service/networkService.js"; +import { IBufferService, BufferService } from "@/service/bufferService.js"; +import { IStatusService, StatusService } from "@/service/statusService.js"; +import { IQueueWorker } from "@/worker/worker.interface.js"; +import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; +import { WorkerHelper } from "@/worker/worker.helper.js"; +import { CheckProducer } from "@/worker/worker.check-producer.js"; +import { CheckEvaluator } from "@/worker/worker.check-evaluator.js"; +import { GeoChecksPipeline } from "@/worker/worker.check-pipeline.js"; +import { NotificationReactor } from "@/worker/reactors/reactor.notification.js"; +import { IncidentReactor } from "@/worker/reactors/reactor.incident.js"; +import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; +import { DBQueueWorker } from "@/worker/worker.db-queue.js"; + +// Network providers +import { PingProvider } from "@/service/network/PingProvider.js"; +import { HttpProvider } from "@/service/network/HttpProvider.js"; +import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js"; +import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js"; +import { HardwareProvider } from "@/service/network/HardwareProvider.js"; +import { DockerProvider } from "@/service/network/DockerProvider.js"; +import { PortProvider } from "@/service/network/PortProvider.js"; +import { GameProvider } from "@/service/network/GameProvider.js"; +import { GrpcProvider } from "@/service/network/GrpcProvider.js"; +import { WebSocketProvider } from "@/service/network/WebSocketProvider.js"; +import { DNSProvider } from "@/service/network/DNSProvider.js"; +export interface WorkerServices { + worker: IQueueWorker; + networkService: INetworkService; + bufferService: IBufferService; + statusService: IStatusService; +} + +export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig): Promise => { + const { + logger, + settingsService, + checkService, + geoChecksService, + notificationsService, + incidentService, + workerId, + jobsRepository, + queueWorkersRepository, + monitorsRepository, + checksRepository, + geoChecksRepository, + monitorStatsRepository, + incidentsRepository, + teamsRepository, + maintenanceWindowsRepository, + } = shared; + + // *********************** + // Network providers + // *********************** + const pingProvider = new PingProvider(ping); + const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath)); + const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger); + const hardwareProvider = new HardwareProvider(httpProvider); + const dockerProvider = new DockerProvider(logger, Docker); + const portProvider = new PortProvider(net); + const gameProvider = new GameProvider(logger, GameDig); + const grpcProvider = new GrpcProvider(grpc, protoLoader); + const webSocketProvider = new WebSocketProvider(WebSocket); + const dnsProvider = new DNSProvider(() => new Resolver()); + + const networkService = new NetworkService(axios, logger, [ + pingProvider, + httpProvider, + pageSpeedProvider, + hardwareProvider, + dockerProvider, + portProvider, + gameProvider, + grpcProvider, + webSocketProvider, + dnsProvider, + ]); + + const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); + const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); + const monitorStatusPolicy = new MonitorStatusPolicy(); + + // *********************** + // Reactors and dispatcher + // Handles notifications and incidents + // *********************** + + const notificationReactor = new NotificationReactor(notificationsService); + const incidentReactor = new IncidentReactor(incidentService); + const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]); + + // *********************** + // Check producer/evaluator + // Handles creating and evaluatiog checks + // *********************** + const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger); + const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); + const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger); + + // *********************** + // Worker + // *********************** + + const workerHelper = new WorkerHelper( + logger, + checkService, + settingsService, + monitorsRepository, + jobsRepository, + teamsRepository, + monitorStatsRepository, + checksRepository, + incidentsRepository, + geoChecksRepository + ); + + const worker = await DBQueueWorker.create( + logger, + () => mongoose.connection.readyState === 1, + jobsRepository, + monitorsRepository, + checksRepository, + checkService, + bufferService, + checkProducer, + checkEvaluator, + geoCheckPipeline, + reactorDispatcher, + workerHelper, + queueWorkersRepository, + envSettings.queueMode, + envSettings.queuePrimaryProcesses, + workerId + ); + + return { worker, networkService, bufferService, statusService }; +}; diff --git a/server/src/db/IDb.ts b/server/src/db/db.interface.ts similarity index 100% rename from server/src/db/IDb.ts rename to server/src/db/db.interface.ts diff --git a/server/src/db/MongoDB.ts b/server/src/db/db.mongo.ts similarity index 92% rename from server/src/db/MongoDB.ts rename to server/src/db/db.mongo.ts index 4531ce5a9..5aca5550a 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/db.mongo.ts @@ -1,11 +1,11 @@ import { Mongoose } from "mongoose"; import mongoose from "mongoose"; -import AppSettings from "@/db/models/AppSettings.js"; +import AppSettings from "@/domain/app-settings/app-settings.model.js"; import { runMigrations } from "@/db/migration/index.js"; import { ILogger } from "@/utils/logger.js"; -import { EnvConfig } from "@/service/system/settingsService.js"; +import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; const SERVICE_NAME = "MongoDB"; -import { IDb } from "@/db/IDb.js"; +import { IDb } from "@/db/db.interface.js"; import { AppError } from "@/utils/AppError.js"; class MongoDB implements IDb { @@ -42,7 +42,7 @@ class MongoDB implements IDb { // the unique index on MonitorStats.monitorId is enforced, otherwise databases // with stray duplicates (caused by the pre-fix race in updateRunningStats) // will crash at startup when the unique index fails to build. - await runMigrations(); + await runMigrations(this.logger); // Sync indexes const models = mongoose.modelNames(); diff --git a/server/src/db/migration/0001_migrateStatusWindowThreshold.ts b/server/src/db/migration/0001_migrateStatusWindowThreshold.ts index d9dd12aa1..c620ffc5f 100644 --- a/server/src/db/migration/0001_migrateStatusWindowThreshold.ts +++ b/server/src/db/migration/0001_migrateStatusWindowThreshold.ts @@ -1,4 +1,4 @@ -import Monitor from "@/db/models/Monitor.js"; +import Monitor from "@/domain/monitors/monitor.model.js"; async function migrateStatusWindowThreshold() { const monitors = await Monitor.find({ statusWindowThreshold: { $lt: 1 } }); for (const monitor of monitors) { diff --git a/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts index c4dff2fc0..39a67d91e 100644 --- a/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts +++ b/server/src/db/migration/0003_cleanupDuplicateMonitorStats.ts @@ -1,12 +1,12 @@ -import { MonitorStatsModel } from "../models/MonitorStats.js"; -import { logger } from "@/utils/logger.js"; +import { MonitorStatsModel } from "../../domain/monitor-stats/monitor-stats.model.js"; +import type { ILogger } from "@/utils/logger.js"; /** * Cleanup duplicate MonitorStats documents * Keeps the most recent document (by updatedAt) for each monitorId * and deletes all older duplicates */ -export async function cleanupDuplicateMonitorStats(): Promise { +export async function cleanupDuplicateMonitorStats(logger: ILogger): Promise { const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStats"; try { diff --git a/server/src/db/migration/0004_fixInfrastructureThresholds.ts b/server/src/db/migration/0004_fixInfrastructureThresholds.ts index bf6458565..c21d552a6 100644 --- a/server/src/db/migration/0004_fixInfrastructureThresholds.ts +++ b/server/src/db/migration/0004_fixInfrastructureThresholds.ts @@ -1,11 +1,11 @@ -import { MonitorModel } from "../models/Monitor.js"; -import { logger } from "@/utils/logger.js"; +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; /** * Fix infrastructure monitors that had thresholds incorrectly set to 0 or 5 * due to the old default value. Updates them to 100 (disabled). */ -export async function fixInfrastructureThresholds(): Promise { +export async function fixInfrastructureThresholds(logger: ILogger): Promise { const SERVICE_NAME = "Migration:FixInfrastructureThresholds"; try { diff --git a/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts b/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts index 0b13d1cb6..d75b069e0 100644 --- a/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts +++ b/server/src/db/migration/0005_migrateStatusPageTypeToArray.ts @@ -1,8 +1,8 @@ import mongoose from "mongoose"; -import { logger } from "@/utils/logger.js"; -import StatusPageModel from "../models/StatusPage.js"; +import type { ILogger } from "@/utils/logger.js"; +import StatusPageModel from "../../domain/status-pages/status-page.model.js"; -export async function migrateStatusPageTypeToArray(): Promise { +export async function migrateStatusPageTypeToArray(logger: ILogger): Promise { const SERVICE_NAME = "Migration:MigrateStatusPageTypeToArray"; try { diff --git a/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts index 814b19776..2479f83c6 100644 --- a/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts +++ b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts @@ -1,5 +1,5 @@ -import { MonitorStatsModel } from "../models/MonitorStats.js"; -import { logger } from "@/utils/logger.js"; +import { MonitorStatsModel } from "../../domain/monitor-stats/monitor-stats.model.js"; +import type { ILogger } from "@/utils/logger.js"; /** * Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced. @@ -11,7 +11,7 @@ import { logger } from "@/utils/logger.js"; * Keeps the document with the highest totalChecks for each monitorId (most data) and * deletes the rest. Falls back to most recent updatedAt when totalChecks tie. */ -export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise { +export async function cleanupDuplicateMonitorStatsForUniqueIndex(logger: ILogger): Promise { const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex"; try { diff --git a/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts index a60b8786a..437c66704 100644 --- a/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts +++ b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts @@ -1,7 +1,7 @@ import mongoose from "mongoose"; import type { AnyBulkWriteOperation, Document } from "mongodb"; -import { logger } from "@/utils/logger.js"; -import MaintenanceWindowModel from "../models/MaintenanceWindow.js"; +import type { ILogger } from "@/utils/logger.js"; +import MaintenanceWindowModel from "../../domain/maintenance-windows/maintenance-window.model.js"; type GroupResult = { _id: { @@ -18,7 +18,7 @@ type GroupResult = { monitorIds: mongoose.Types.ObjectId[]; }; -export async function migrateMaintenanceWindowMonitorIdToArray(): Promise { +export async function migrateMaintenanceWindowMonitorIdToArray(logger: ILogger): Promise { const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray"; try { diff --git a/server/src/db/migration/0008_backfillMonitorLastEvaluatedAt.ts b/server/src/db/migration/0008_backfillMonitorLastEvaluatedAt.ts new file mode 100644 index 000000000..148c4b74d --- /dev/null +++ b/server/src/db/migration/0008_backfillMonitorLastEvaluatedAt.ts @@ -0,0 +1,41 @@ +import mongoose from "mongoose"; +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +const JOBS_COLLECTION = "jobs"; + +/** + * Backfill lastEvaluatedAt on monitors created before the lastEvaluatedAt existed. + * + * Stamp the upgrade moment, not 0. If set to 0, all checks will be replayed + */ +export async function backfillMonitorLastEvaluatedAt(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:BackfillMonitorLastEvaluatedAt"; + + try { + // Drop the legacy jobs collection first. The old super-simple-scheduler persisted jobs to the + // same "jobs" collection the new db-queue uses, so any leftover rows carry an incompatible schema. + // Migrations run before syncIndexes(), so the new Job indexes are rebuilt right after this drop. + const db = mongoose.connection.db; + if (db) { + const existing = await db.listCollections({ name: JOBS_COLLECTION }).toArray(); + if (existing.length > 0) { + await db.collection(JOBS_COLLECTION).drop(); + logger.info({ service: SERVICE_NAME, message: `Dropped legacy "${JOBS_COLLECTION}" collection` }); + } + } + + logger.info({ service: SERVICE_NAME, message: "Starting lastEvaluatedAt backfill" }); + + const result = await MonitorModel.updateMany({ lastEvaluatedAt: { $exists: false } }, { $set: { lastEvaluatedAt: Date.now() } }); + + logger.info({ + service: SERVICE_NAME, + message: `Backfilled lastEvaluatedAt on ${result.modifiedCount} monitors`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error backfilling lastEvaluatedAt: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 61785118e..0b0745d19 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -2,15 +2,16 @@ import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshol import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js"; import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js"; import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js"; -import MigrationModel from "../models/Migration.js"; +import MigrationModel from "../../domain/migrations/migration.model.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; +import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { name: string; - execute: () => Promise; + execute: (logger: ILogger) => Promise; }; const migrations: MigrationEntry[] = [ @@ -21,36 +22,37 @@ const migrations: MigrationEntry[] = [ { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, + { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, ]; -const runMigrations = async (logger?: ILogger) => { +const runMigrations = async (logger: ILogger) => { try { - logger?.info({ message: "Running migrations", service: "Migrations" }); + logger.info({ message: "Running migrations", service: "Migrations" }); for (const migration of migrations) { const exists = await MigrationModel.findOne({ name: migration.name, status: "completed" }); if (exists) { - logger?.info({ message: `Skipping ${migration.name}`, service: "Migrations" }); + logger.info({ message: `Skipping ${migration.name}`, service: "Migrations" }); continue; } try { - await migration.execute(); + await migration.execute(logger); await MigrationModel.findOneAndUpdate( { name: migration.name }, { status: "completed", completedAt: new Date(), error: undefined }, { upsert: true } ); - logger?.info({ message: `Completed ${migration.name}`, service: "Migrations" }); + logger.info({ message: `Completed ${migration.name}`, service: "Migrations" }); } catch (error) { const err = error as Error; await MigrationModel.findOneAndUpdate({ name: migration.name }, { status: "failed", error: err?.message }, { upsert: true }); throw error; } } - logger?.info({ message: "Migrations completed", service: "Migrations" }); + logger.info({ message: "Migrations completed", service: "Migrations" }); } catch (error) { const err = error as Error; - logger?.error({ message: "Migration failed", service: "Migrations", details: { error: err?.message }, stack: err?.stack }); + logger.error({ message: "Migration failed", service: "Migrations", details: { error: err?.message }, stack: err?.stack }); throw error; } }; diff --git a/server/src/db/models/QueueWorker.ts b/server/src/db/models/QueueWorker.ts deleted file mode 100644 index 76485f56c..000000000 --- a/server/src/db/models/QueueWorker.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Schema, model } from "mongoose"; -import { QueueModes, type QueueMode } from "@/types/settings.js"; - -// Heartbeat presence record for a scheduler instance. _id is the scheduler's -// workerId (hostname:pid:uuid). The TTL index on lastSeenAt garbage-collects -// records for instances that have stopped heartbeating. -interface QueueWorkerDocument { - _id: string; - mode: QueueMode; - lastSeenAt: Date; - createdAt: Date; - updatedAt: Date; -} - -const QueueWorkerSchema = new Schema( - { - _id: { type: String, required: true }, - mode: { type: String, enum: QueueModes, required: true }, - lastSeenAt: { type: Date, default: Date.now, expires: 30 }, - }, - { timestamps: true } -); - -const QueueWorkerModel = model("QueueWorker", QueueWorkerSchema); - -export type { QueueWorkerDocument }; -export { QueueWorkerModel }; -export default QueueWorkerModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts deleted file mode 100644 index b825ca503..000000000 --- a/server/src/db/models/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -export * from "@/db/models/Monitor.js"; -export { default as MonitorModel } from "@/db/models/Monitor.js"; - -export * from "@/db/models/Check.js"; -export { default as CheckModel } from "@/db/models/Check.js"; - -export * from "@/db/models/MonitorStats.js"; -export { default as MonitorStatsModel } from "@/db/models/MonitorStats.js"; - -export * from "@/db/models/StatusPage.js"; -export { default as StatusPageModel } from "@/db/models/StatusPage.js"; - -export * from "@/db/models/User.js"; -export { default as UserModel } from "@/db/models/User.js"; - -export * from "@/db/models/Invite.js"; -export { default as InviteModel } from "@/db/models/Invite.js"; - -export * from "@/db/models/AppSettings.js"; -export { default as AppSettingsModel } from "@/db/models/AppSettings.js"; - -export * from "@/db/models/RecoveryToken.js"; -export { default as RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; - -export * from "@/db/models/Notification.js"; -export { default as NotificationModel } from "@/db/models/Notification.js"; - -export * from "@/db/models/Incident.js"; -export { default as IncidentModel } from "@/db/models/Incident.js"; - -export * from "@/db/models/Team.js"; -export { default as TeamModel } from "@/db/models/Team.js"; - -export * from "@/db/models/MaintenanceWindow.js"; -export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow.js"; - -export * from "@/db/models/GeoCheck.js"; -export { default as GeoCheckModel } from "@/db/models/GeoCheck.js"; - -export * from "@/db/models/DLQItem.js"; -export { default as DLQItemModel } from "@/db/models/DLQItem.js"; -export * from "@/db/models/Tag.js"; -export { default as TagModel } from "@/db/models/Tag.js"; - -export * from "@/db/models/QueueWorker.js"; -export { default as QueueWorkerModel } from "@/db/models/QueueWorker.js"; diff --git a/server/src/repositories/settings/ISettingsRepository.ts b/server/src/domain/app-settings/app-settings-repository.interface.ts similarity index 75% rename from server/src/repositories/settings/ISettingsRepository.ts rename to server/src/domain/app-settings/app-settings-repository.interface.ts index 0485a5aea..401d867f7 100644 --- a/server/src/repositories/settings/ISettingsRepository.ts +++ b/server/src/domain/app-settings/app-settings-repository.interface.ts @@ -1,4 +1,4 @@ -import type { Settings, SettingsUpdate } from "@/types/index.js"; +import type { Settings, SettingsUpdate } from "@/domain/app-settings/app-settings.type.js"; export interface ISettingsRepository { // create create(settings: Partial): Promise; diff --git a/server/src/db/models/AppSettings.ts b/server/src/domain/app-settings/app-settings.model.ts similarity index 94% rename from server/src/db/models/AppSettings.ts rename to server/src/domain/app-settings/app-settings.model.ts index 195f43044..da766fa83 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/domain/app-settings/app-settings.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { Settings, SettingsThresholds } from "@/types/settings.js"; +import type { Settings, SettingsThresholds } from "@/domain/app-settings/app-settings.type.js"; interface AppSettingsDocument extends Omit { _id: Types.ObjectId; diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/domain/app-settings/app-settings.repository.mongo.ts similarity index 91% rename from server/src/repositories/settings/MongoSettingsRepository.ts rename to server/src/domain/app-settings/app-settings.repository.mongo.ts index 3117a94ea..ab242e4b7 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/domain/app-settings/app-settings.repository.mongo.ts @@ -1,7 +1,7 @@ import mongoose, { type UpdateQuery } from "mongoose"; -import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; -import type { Settings, SettingsUpdate } from "@/types/index.js"; -import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js"; +import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js"; +import type { Settings, SettingsUpdate } from "@/domain/app-settings/app-settings.type.js"; +import { AppSettingsModel, type AppSettingsDocument } from "@/domain/app-settings/app-settings.model.js"; class MongoSettingsRepository implements ISettingsRepository { private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { diff --git a/server/src/service/system/settingsService.ts b/server/src/domain/app-settings/app-settings.service.ts similarity index 86% rename from server/src/service/system/settingsService.ts rename to server/src/domain/app-settings/app-settings.service.ts index dfbf6cfdc..8beb65d84 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/domain/app-settings/app-settings.service.ts @@ -1,7 +1,7 @@ -import { ISettingsRepository } from "@/repositories/index.js"; -import { type DbType, type QueueType, type QueueMode, Settings, SettingsUpdate } from "@/types/index.js"; +import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js"; +import { Settings, SettingsUpdate, type DbType, type QueueMode } from "@/domain/app-settings/app-settings.type.js"; import { AppError } from "@/utils/AppError.js"; -import { ValidatedEnv } from "@/validation/envValidation.js"; +import { ValidatedEnv } from "@/config/envValidation.js"; import type { StringValue } from "ms"; const SERVICE_NAME = "SettingsService"; @@ -13,8 +13,8 @@ export type EnvConfig = { clientHost: string; dbConnectionString: string; dbType: DbType; - queueType: QueueType; queueMode: QueueMode; + queuePrimaryProcesses: boolean; statusPageThemesEnabled: boolean; }; @@ -41,8 +41,8 @@ export class SettingsService implements ISettingsService { clientHost: env.CLIENT_HOST, dbConnectionString: env.DB_CONNECTION_STRING, dbType: env.DB_TYPE, - queueType: env.QUEUE_TYPE, queueMode: env.QUEUE_MODE, + queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES, statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED, }; } diff --git a/server/src/types/settings.ts b/server/src/domain/app-settings/app-settings.type.ts similarity index 73% rename from server/src/types/settings.ts rename to server/src/domain/app-settings/app-settings.type.ts index 4a4ff02a2..8cb52e93d 100644 --- a/server/src/types/settings.ts +++ b/server/src/domain/app-settings/app-settings.type.ts @@ -1,18 +1,9 @@ export const DbTypes = ["mongodb"] as const; export type DbType = (typeof DbTypes)[number]; -export const QueueTypes = ["lessSimpleQueue", "superSimpleQueue"] as const; -export type QueueType = (typeof QueueTypes)[number]; - export const QueueModes = ["primary", "worker"] as const; export type QueueMode = (typeof QueueModes)[number]; -export type QueueWorker = { - workerId: string; // hostname:pid:uuid - mode: QueueMode; - lastSeenAt: number; // epoch ms of the last heartbeat -}; - export interface SettingsThresholds { cpu?: number; memory?: number; @@ -50,3 +41,20 @@ export interface Settings { createdAt: string; updatedAt: string; } + +export type EmailTransportConfig = Pick< + Settings, + | "systemEmailHost" + | "systemEmailPort" + | "systemEmailAddress" + | "systemEmailDisplayName" + | "systemEmailPassword" + | "systemEmailUser" + | "systemEmailConnectionHost" + | "systemEmailTLSServername" + | "systemEmailSecure" + | "systemEmailPool" + | "systemEmailIgnoreTLS" + | "systemEmailRequireTLS" + | "systemEmailRejectUnauthorized" +>; diff --git a/server/src/db/models/Check.ts b/server/src/domain/checks/check.model.ts similarity index 97% rename from server/src/db/models/Check.ts rename to server/src/domain/checks/check.model.ts index 964936af9..c3fbc23ff 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/domain/checks/check.model.ts @@ -1,5 +1,5 @@ import { Schema, model, Types } from "mongoose"; -import { MonitorTypes, type MonitorType } from "@/types/monitor.js"; +import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js"; import type { Check, CheckAudits, @@ -13,7 +13,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, -} from "@/types/check.js"; +} from "@/domain/checks/check.type.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; @@ -241,6 +241,7 @@ const CheckSchema = new Schema( }, errors: { type: [errorSchema], + suppressReservedKeysWarning: true, default: () => [], }, capture: { diff --git a/server/src/repositories/checks/IChecksRepository.ts b/server/src/domain/checks/check.repository.interface.ts similarity index 83% rename from server/src/repositories/checks/IChecksRepository.ts rename to server/src/domain/checks/check.repository.interface.ts index b96d14933..95c0ade80 100644 --- a/server/src/repositories/checks/IChecksRepository.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -2,12 +2,12 @@ import type { Check, ChecksQueryResult, ChecksSummary, - MonitorType, - PageSpeedChecksResult, HardwareChecksResult, + PageSpeedChecksResult, UptimeChecksResult, -} from "@/types/index.js"; -import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js"; +} from "@/domain/checks/check.type.js"; +import type { MonitorType } from "@/domain/monitors/monitor.types.js"; +import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js"; export interface IChecksRepository { // create @@ -42,6 +42,7 @@ export interface IChecksRepository { options?: { type?: MonitorType } ): Promise; findSummaryByTeamId(teamId: string, dateRange: string): Promise; + findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; // update //delete deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/repositories/checks/MongoChecksRepistory.ts b/server/src/domain/checks/check.repository.mongo.ts similarity index 97% rename from server/src/repositories/checks/MongoChecksRepistory.ts rename to server/src/domain/checks/check.repository.mongo.ts index 4055ddfc3..4f9ad6f4c 100644 --- a/server/src/repositories/checks/MongoChecksRepistory.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -1,4 +1,4 @@ -import { IChecksRepository } from "@/repositories/index.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import type { Check, CheckAudits, @@ -11,9 +11,9 @@ import type { CheckMetadata, CheckNetworkInterfaceInfo, GotTimings, - MonitorType, -} from "@/types/index.js"; -import { CheckModel, type CheckDocument } from "@/db/models/index.js"; +} from "@/domain/checks/check.type.js"; +import type { MonitorType } from "@/domain/monitors/monitor.types.js"; +import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js"; import mongoose from "mongoose"; import { getDateForRange } from "@/utils/dataUtils.js"; import { ILogger } from "@/utils/logger.js"; @@ -386,6 +386,16 @@ class MongoChecksRepository implements IChecksRepository { }; }; + findUnevaluatedByMonitorId = async (monitorId: string, since: number) => { + const docs = await CheckModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + createdAt: { $gt: new Date(since) }, + }) + .sort({ createdAt: 1 }) + .lean(); + return docs.map(this.toEntity); + }; + deleteByMonitorId = async (monitorId: string): Promise => { const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }); return result.deletedCount; diff --git a/server/src/service/business/checkService.ts b/server/src/domain/checks/check.service.ts similarity index 79% rename from server/src/service/business/checkService.ts rename to server/src/domain/checks/check.service.ts index dd2a66202..ca7fca457 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/domain/checks/check.service.ts @@ -1,14 +1,8 @@ import { Types } from "mongoose"; -import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js"; -import type { - MonitorStatusResponse, - CheckErrorInfo, - Check, - ILighthouseAudit, - ChecksQueryResult, - ChecksSummary, - MonitorPayloadMap, -} from "@/types/index.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { Check, CheckErrorInfo, ChecksQueryResult, ChecksSummary, ILighthouseAudit } from "@/domain/checks/check.type.js"; +import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; @@ -17,7 +11,9 @@ const SERVICE_NAME = "checkService"; export interface ICheckService { createChecks(checks: Check[]): Promise; - buildCheck(statusResponse: MonitorStatusResponse): Check | undefined; + toLastEvaluatedAt(check: Check): number; + toCheck(statusResponse: MonitorStatusResponse): Check | undefined; + toStatusResponse(check: Check): MonitorStatusResponse; getChecksByMonitor(params: { monitorId: string; teamId: string; @@ -62,7 +58,11 @@ export class CheckService implements ICheckService { return this.checksRepository.createChecks(checks); }; - buildCheck = (statusResponse: MonitorStatusResponse): Check | undefined => { + toLastEvaluatedAt = (check: Check) => { + return new Date(check.createdAt).getTime(); + }; + + toCheck = (statusResponse: MonitorStatusResponse): Check | undefined => { const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse; const now = new Date().toISOString(); @@ -88,7 +88,7 @@ export class CheckService implements ICheckService { this.logger.warn({ message: "Failed to build check", service: SERVICE_NAME, - method: "buildCheck", + method: "toCheck", }); return undefined; } @@ -137,6 +137,24 @@ export class CheckService implements ICheckService { return check; }; + toStatusResponse = (check: Check) => { + const statusResponse: MonitorStatusResponse = { + monitorId: check.metadata.monitorId, + teamId: check.metadata.teamId, + type: check.metadata.type, + status: check.status, + code: check.statusCode, + message: check.message, + responseTime: check.responseTime, + // re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data + payload: + check.metadata.type === "hardware" + ? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload) + : undefined, + }; + return statusResponse; + }; + getChecksByMonitor = async ({ monitorId, teamId, diff --git a/server/src/types/check.ts b/server/src/domain/checks/check.type.ts similarity index 98% rename from server/src/types/check.ts rename to server/src/domain/checks/check.type.ts index f4c64f926..fe733e4e9 100644 --- a/server/src/types/check.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,4 +1,4 @@ -import type { MonitorType } from "@/types/index.js"; +import type { MonitorType } from "@/domain/monitors/monitor.types.js"; import type { Response } from "got"; export const CHECK_TTL_SENTINEL = 366; diff --git a/server/src/service/business/diagnosticService.ts b/server/src/domain/diagnostics/diagnostic.service.ts similarity index 58% rename from server/src/service/business/diagnosticService.ts rename to server/src/domain/diagnostics/diagnostic.service.ts index 2d9968240..f26fa02e3 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/domain/diagnostics/diagnostic.service.ts @@ -1,11 +1,27 @@ import v8 from "v8"; import os from "os"; -import { IDb } from "@/db/IDb.js"; +import { IDb } from "@/db/db.interface.js"; import { Mongoose } from "mongoose"; import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "diagnosticService"; +export interface MongoStats { + reads: number; + inserts: number; + updates: number; + deletes: number; + timestamp: number; +} + +export interface MongoOpsPerSecond { + readsPerSecond: number; + insertsPerSecond: number; + updatesPerSecond: number; + deletesPerSecond: number; + writesPerSecond: number; +} + export interface CollectionDiagnostics { name: string; documentCount: number; @@ -17,6 +33,11 @@ export interface CollectionDiagnostics { export interface MongoDiagnostics { readyState: number; + readsPerSecond: number; + insertsPerSecond: number; + updatesPerSecond: number; + deletesPerSecond: number; + writesPerSecond: number; host: string; port: number; dbName: string; @@ -44,6 +65,8 @@ export interface IDiagnosticService { export class DiagnosticService implements IDiagnosticService { static SERVICE_NAME = SERVICE_NAME; + private lastMongoOpsPerSecond: MongoStats | null = null; + constructor(private db: IDb) { /** * Performance Observer for monitoring system performance metrics. @@ -68,6 +91,48 @@ export class DiagnosticService implements IDiagnosticService { return ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100; }; + private getMongoOpsPerSecond = async (): Promise => { + try { + const mongo = await this.db.getConnection(); + const db = mongo.connection.db; + if (!db) { + throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" }); + } + const sample = async (): Promise => { + const res = await db.command({ serverStatus: 1 }); + return { + reads: (res.opcounters?.query ?? 0) + (res.opcounters?.getmore ?? 0), + inserts: res.opcounters?.insert ?? 0, + updates: res.opcounters?.update ?? 0, + deletes: res.opcounters?.delete ?? 0, + timestamp: Date.now(), + }; + }; + + // First call has no baseline, seed one so we always have a rate + if (this.lastMongoOpsPerSecond === null) { + this.lastMongoOpsPerSecond = await sample(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + const previous = this.lastMongoOpsPerSecond; + const current = await sample(); + this.lastMongoOpsPerSecond = current; + + const elapsedSeconds = (current.timestamp - previous.timestamp) / 1000; + return { + readsPerSecond: (current.reads - previous.reads) / elapsedSeconds, + insertsPerSecond: (current.inserts - previous.inserts) / elapsedSeconds, + updatesPerSecond: (current.updates - previous.updates) / elapsedSeconds, + deletesPerSecond: (current.deletes - previous.deletes) / elapsedSeconds, + writesPerSecond: + (current.inserts - previous.inserts + current.updates - previous.updates + current.deletes - previous.deletes) / elapsedSeconds, + }; + } catch { + return null; + } + }; + private getMongoDBStats = async (): Promise => { const mongo = await this.db.getConnection(); const db = mongo.connection.db; @@ -75,6 +140,8 @@ export class DiagnosticService implements IDiagnosticService { throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" }); } + const opsPerSecond = await this.getMongoOpsPerSecond(); + const dbStats = await db.stats(); const rawCollections = await db.collections(); @@ -87,9 +154,9 @@ export class DiagnosticService implements IDiagnosticService { const entry: CollectionDiagnostics = { name: collection.collectionName, documentCount, - storageSize: stats.storageSize, - totalIndexSize: stats.totalIndexSize, - totalSize: stats.totalSize, + storageSize: stats.storageSize ?? 0, + totalIndexSize: stats.totalIndexSize ?? 0, + totalSize: stats.totalSize ?? (stats.storageSize ?? 0) + (stats.totalIndexSize ?? 0), }; if (stats.timeseries?.bucketCount !== undefined) { entry.bucketCount = stats.timeseries.bucketCount; @@ -100,10 +167,15 @@ export class DiagnosticService implements IDiagnosticService { return { readyState: mongo.connection.readyState, + readsPerSecond: opsPerSecond?.readsPerSecond ?? 0, + insertsPerSecond: opsPerSecond?.insertsPerSecond ?? 0, + updatesPerSecond: opsPerSecond?.updatesPerSecond ?? 0, + deletesPerSecond: opsPerSecond?.deletesPerSecond ?? 0, + writesPerSecond: opsPerSecond?.writesPerSecond ?? 0, host: mongo.connection.host, port: mongo.connection.port, dbName: mongo.connection.name, - totalSize: dbStats.totalSize, + totalSize: dbStats.totalSize ?? (dbStats.storageSize ?? 0) + (dbStats.indexSize ?? 0), collections, }; }; diff --git a/server/src/db/models/DLQItem.ts b/server/src/domain/dlq/dlq.model.ts similarity index 93% rename from server/src/db/models/DLQItem.ts rename to server/src/domain/dlq/dlq.model.ts index 707846adb..1ece5a028 100644 --- a/server/src/db/models/DLQItem.ts +++ b/server/src/domain/dlq/dlq.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import { DLQItemTypes, DLQItemStatuses, type DLQItem } from "@/types/dlqItem.js"; +import { DLQItemTypes, DLQItemStatuses, type DLQItem } from "@/domain/dlq/dlq.type.js"; export interface DLQItemDocument extends Omit { _id: Types.ObjectId; diff --git a/server/src/repositories/dlq/IDLQRepository.ts b/server/src/domain/dlq/dlq.repository.interface.ts similarity index 90% rename from server/src/repositories/dlq/IDLQRepository.ts rename to server/src/domain/dlq/dlq.repository.interface.ts index 5e26e6096..43d4f75d5 100644 --- a/server/src/repositories/dlq/IDLQRepository.ts +++ b/server/src/domain/dlq/dlq.repository.interface.ts @@ -1,4 +1,4 @@ -import type { DLQItem, DLQItemType, DLQItemStatus } from "@/types/index.js"; +import type { DLQItem, DLQItemStatus, DLQItemType } from "@/domain/dlq/dlq.type.js"; export interface DLQQueryFilters { type?: DLQItemType; diff --git a/server/src/repositories/dlq/MongoDLQRepository.ts b/server/src/domain/dlq/dlq.repository.mongo.ts similarity index 94% rename from server/src/repositories/dlq/MongoDLQRepository.ts rename to server/src/domain/dlq/dlq.repository.mongo.ts index 8c9da76ad..4cf7bad85 100644 --- a/server/src/repositories/dlq/MongoDLQRepository.ts +++ b/server/src/domain/dlq/dlq.repository.mongo.ts @@ -1,7 +1,7 @@ -import { DLQItemModel } from "@/db/models/index.js"; -import type { DLQItemDocument } from "@/db/models/DLQItem.js"; -import type { DLQItem } from "@/types/index.js"; -import type { IDLQRepository, DLQQueryFilters, DLQStatusCount } from "@/repositories/dlq/IDLQRepository.js"; +import { DLQItemModel } from "@/domain/dlq/dlq.model.js"; +import type { DLQItemDocument } from "@/domain/dlq/dlq.model.js"; +import type { DLQItem } from "@/domain/dlq/dlq.type.js"; +import type { IDLQRepository, DLQQueryFilters, DLQStatusCount } from "@/domain/dlq/dlq.repository.interface.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/types/dlqItem.ts b/server/src/domain/dlq/dlq.type.ts similarity index 100% rename from server/src/types/dlqItem.ts rename to server/src/domain/dlq/dlq.type.ts diff --git a/server/src/db/models/GeoCheck.ts b/server/src/domain/geo-checks/geo-check.model.ts similarity index 94% rename from server/src/db/models/GeoCheck.ts rename to server/src/domain/geo-checks/geo-check.model.ts index 8c0749f00..0db895d0a 100644 --- a/server/src/db/models/GeoCheck.ts +++ b/server/src/domain/geo-checks/geo-check.model.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; -import { MonitorTypes, type MonitorType } from "@/types/monitor.js"; -import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/types/geoCheck.js"; +import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.types.js"; +import type { GeoCheck, GeoCheckLocation, GeoCheckMetadata, GeoCheckResult, GeoCheckTimings } from "@/domain/geo-checks/geo-check.type.js"; type GeoCheckMetadataDocument = Omit & { monitorId: Types.ObjectId; diff --git a/server/src/repositories/geo-checks/IGeoChecksRepository.ts b/server/src/domain/geo-checks/geo-check.repository.interface.ts similarity index 84% rename from server/src/repositories/geo-checks/IGeoChecksRepository.ts rename to server/src/domain/geo-checks/geo-check.repository.interface.ts index fe4beb796..2e6f33e33 100644 --- a/server/src/repositories/geo-checks/IGeoChecksRepository.ts +++ b/server/src/domain/geo-checks/geo-check.repository.interface.ts @@ -1,5 +1,5 @@ -import type { GeoCheck, GroupedGeoCheck } from "@/types/geoCheck.js"; -import type { GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js"; +import type { GeoCheck, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; +import type { GeoContinent, FlatGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; export interface GeoChecksQueryResult { geoChecksCount: number; diff --git a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts b/server/src/domain/geo-checks/geo-check.repository.mongo.ts similarity index 97% rename from server/src/repositories/geo-checks/MongoGeoChecksRepository.ts rename to server/src/domain/geo-checks/geo-check.repository.mongo.ts index 0adcf7c9f..426fb533d 100644 --- a/server/src/repositories/geo-checks/MongoGeoChecksRepository.ts +++ b/server/src/domain/geo-checks/geo-check.repository.mongo.ts @@ -1,7 +1,7 @@ -import { IGeoChecksRepository } from "./IGeoChecksRepository.js"; -import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js"; -import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; -import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js"; +import { IGeoChecksRepository } from "./geo-check.repository.interface.js"; +import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; +import type { FlatGeoChecksQueryResult } from "./geo-check.repository.interface.js"; +import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/domain/geo-checks/geo-check.model.js"; import mongoose, { PipelineStage } from "mongoose"; import { getDateForRange } from "@/utils/dataUtils.js"; import { ILogger } from "@/utils/logger.js"; diff --git a/server/src/service/business/geoChecksService.ts b/server/src/domain/geo-checks/geo-check.service.ts similarity index 91% rename from server/src/service/business/geoChecksService.ts rename to server/src/domain/geo-checks/geo-check.service.ts index 95b945f2a..0ace565fc 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/domain/geo-checks/geo-check.service.ts @@ -1,9 +1,10 @@ -import type { Monitor, GeoCheck } from "@/types/index.js"; -import type { GeoCheckResult, GeoContinent } from "@/types/geoCheck.js"; +import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js"; +import type { Monitor } from "@/domain/monitors/monitor.types.js"; +import type { GeoCheckResult, GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import { Types } from "mongoose"; -import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/repositories/index.js"; -import type { IMonitorsRepository } from "@/repositories/index.js"; -import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js"; +import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; +import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { IGlobalPingService } from "@/service/globalPingService.js"; import type { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; @@ -89,7 +90,7 @@ export class GeoChecksService implements IGeoChecksService { } // Step 2: Poll for results - const results = await this.globalPingService.pollForResults(measurementId); + const results = await this.globalPingService.pollForResults(measurementId, undefined, monitor.customUpCodes ?? []); if (results.length === 0) { // No successful results (all locations timed out or failed) diff --git a/server/src/types/geoCheck.ts b/server/src/domain/geo-checks/geo-check.type.ts similarity index 95% rename from server/src/types/geoCheck.ts rename to server/src/domain/geo-checks/geo-check.type.ts index 548c4c65f..044bec3b0 100644 --- a/server/src/types/geoCheck.ts +++ b/server/src/domain/geo-checks/geo-check.type.ts @@ -1,4 +1,4 @@ -import type { MonitorType } from "@/types/index.js"; +import type { MonitorType } from "@/domain/monitors/monitor.types.js"; export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const; export type GeoContinent = (typeof GeoContinents)[number]; diff --git a/server/src/db/models/Incident.ts b/server/src/domain/incidents/incident.model.ts similarity index 95% rename from server/src/db/models/Incident.ts rename to server/src/domain/incidents/incident.model.ts index 82e2b5eb2..9e252bb1d 100644 --- a/server/src/db/models/Incident.ts +++ b/server/src/domain/incidents/incident.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import { IncidentResolutionTypes, type Incident } from "@/types/incident.js"; +import { IncidentResolutionTypes, type Incident } from "@/domain/incidents/incident.type.js"; type IncidentDocumentBase = Omit & { monitorId: Types.ObjectId; diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/domain/incidents/incident.repository.interface.ts similarity index 88% rename from server/src/repositories/incidents/IIncidentsRepository.ts rename to server/src/domain/incidents/incident.repository.interface.ts index c4fcef2ae..549a33b85 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/domain/incidents/incident.repository.interface.ts @@ -1,5 +1,5 @@ -import type { Incident } from "@/types/index.js"; -import type { IncidentSummary } from "@/types/index.js"; +import type { Incident } from "@/domain/incidents/incident.type.js"; +import type { IncidentSummary } from "@/domain/incidents/incident.type.js"; export interface IIncidentsRepository { // create create(incident: Partial): Promise; diff --git a/server/src/repositories/incidents/MongoIncidentsRepository.ts b/server/src/domain/incidents/incident.repository.mongo.ts similarity index 96% rename from server/src/repositories/incidents/MongoIncidentsRepository.ts rename to server/src/domain/incidents/incident.repository.mongo.ts index e24551462..4cf410e88 100644 --- a/server/src/repositories/incidents/MongoIncidentsRepository.ts +++ b/server/src/domain/incidents/incident.repository.mongo.ts @@ -1,7 +1,7 @@ -import { IncidentModel } from "@/db/models/index.js"; -import type { IncidentDocument } from "@/db/models/Incident.js"; -import type { Incident, IncidentSummary } from "@/types/index.js"; -import type { IIncidentsRepository } from "@/repositories/index.js"; +import { IncidentModel } from "@/domain/incidents/incident.model.js"; +import type { IncidentDocument } from "@/domain/incidents/incident.model.js"; +import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js"; +import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/service/business/incidentService.ts b/server/src/domain/incidents/incident.service.ts similarity index 92% rename from server/src/service/business/incidentService.ts rename to server/src/domain/incidents/incident.service.ts index 1606cc95d..311fad567 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/domain/incidents/incident.service.ts @@ -1,12 +1,15 @@ const SERVICE_NAME = "incidentService"; -import type { Monitor } from "@/types/monitor.js"; +import type { Monitor } from "@/domain/monitors/monitor.types.js"; import type { MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { getDateForRange } from "@/utils/dataUtils.js"; -import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; -import type { Incident, IncidentSummary, User } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; -import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; +import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js"; +import type { User } from "@/domain/users/user.type.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; +import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import type { ILogger } from "@/utils/logger.js"; export interface IIncidentService { diff --git a/server/src/types/incident.ts b/server/src/domain/incidents/incident.type.ts similarity index 100% rename from server/src/types/incident.ts rename to server/src/domain/incidents/incident.type.ts diff --git a/server/src/db/models/Invite.ts b/server/src/domain/invites/invite.model.ts similarity index 93% rename from server/src/db/models/Invite.ts rename to server/src/domain/invites/invite.model.ts index 25ddd9a72..a2bd6ae74 100755 --- a/server/src/db/models/Invite.ts +++ b/server/src/domain/invites/invite.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { Invite } from "@/types/invite.js"; +import type { Invite } from "@/domain/invites/invite.type.js"; type InviteDocumentBase = Omit & { teamId: Types.ObjectId; diff --git a/server/src/repositories/invites/IInvitesRepository.ts b/server/src/domain/invites/invite.repository.interface.ts similarity index 79% rename from server/src/repositories/invites/IInvitesRepository.ts rename to server/src/domain/invites/invite.repository.interface.ts index 74a7ad68c..348731e9c 100644 --- a/server/src/repositories/invites/IInvitesRepository.ts +++ b/server/src/domain/invites/invite.repository.interface.ts @@ -1,4 +1,4 @@ -import type { Invite } from "@/types/index.js"; +import type { Invite } from "@/domain/invites/invite.type.js"; export interface IInvitesRepository { // create diff --git a/server/src/repositories/invites/MongoInviteRepository.ts b/server/src/domain/invites/invite.repository.mongo.ts similarity index 88% rename from server/src/repositories/invites/MongoInviteRepository.ts rename to server/src/domain/invites/invite.repository.mongo.ts index a8187d09a..8956a08b3 100644 --- a/server/src/repositories/invites/MongoInviteRepository.ts +++ b/server/src/domain/invites/invite.repository.mongo.ts @@ -1,6 +1,6 @@ -import { IInvitesRepository } from "@/repositories/index.js"; -import type { Invite } from "@/types/index.js"; -import { type InviteDocument, InviteModel } from "@/db/models/index.js"; +import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js"; +import type { Invite } from "@/domain/invites/invite.type.js"; +import { type InviteDocument, InviteModel } from "@/domain/invites/invite.model.js"; import { AppError } from "@/utils/AppError.js"; import mongoose from "mongoose"; import crypto from "crypto"; diff --git a/server/src/service/business/inviteService.ts b/server/src/domain/invites/invite.service.ts similarity index 88% rename from server/src/service/business/inviteService.ts rename to server/src/domain/invites/invite.service.ts index 7e2b7de52..76c5c6289 100644 --- a/server/src/service/business/inviteService.ts +++ b/server/src/domain/invites/invite.service.ts @@ -1,9 +1,10 @@ -import type { Invite, UserRole } from "@/types/index.js"; -import { canManageRole } from "@/types/user.js"; -import type { IInvitesRepository } from "@/repositories/index.js"; +import type { Invite } from "@/domain/invites/invite.type.js"; +import type { UserRole } from "@/domain/users/user.type.js"; +import { canManageRole } from "@/domain/users/user.type.js"; +import type { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js"; import { AppError } from "@/utils/AppError.js"; -import { ISettingsService } from "../system/settingsService.js"; -import { IEmailService } from "../infrastructure/emailService.js"; +import { ISettingsService } from "../app-settings/app-settings.service.js"; +import { IEmailService } from "@/service/emailService.js"; const SERVICE_NAME = "inviteService"; diff --git a/server/src/types/invite.ts b/server/src/domain/invites/invite.type.ts similarity index 73% rename from server/src/types/invite.ts rename to server/src/domain/invites/invite.type.ts index cdbdb37e5..e00c75d6b 100644 --- a/server/src/types/invite.ts +++ b/server/src/domain/invites/invite.type.ts @@ -1,4 +1,4 @@ -import type { UserRole } from "@/types/user.js"; +import type { UserRole } from "@/domain/users/user.type.js"; export interface Invite { id: string; diff --git a/server/src/domain/jobs/job.model.ts b/server/src/domain/jobs/job.model.ts new file mode 100644 index 000000000..f64ef53b8 --- /dev/null +++ b/server/src/domain/jobs/job.model.ts @@ -0,0 +1,79 @@ +import { Schema, model } from "mongoose"; +import { JOB_TYPES, type Job } from "@/domain/jobs/job.type.js"; + +export interface JobDocument extends Omit { + _id: string; +} + +const JobSchema = new Schema( + { + _id: { + type: String, + required: true, + }, + type: { + type: String, + enum: JOB_TYPES, + required: true, + }, + // monitorId for monitor-bound jobs; null for global cleanup jobs. Kept as a String pointer (no populate). + refId: { + type: String, + default: null, + }, + // paused monitor => false; the claim scan skips inactive rows (replaces pauseJob/resumeJob) + isActive: { + type: Boolean, + default: true, + }, + + // Scheduling — epoch ms, so the claim/$min queries operate on plain numbers + nextScheduledAt: { + type: Number, + required: true, + }, + intervalMs: { + type: Number, + default: null, + }, + + // Lock / lease + lockedBy: { + type: String, + default: null, + }, + lockedUntil: { + type: Number, + default: null, + }, + + // Observability + runCount: { + type: Number, + default: 0, + }, + failCount: { + type: Number, + default: 0, + }, + lastFinishedAt: { + type: Number, + default: null, + }, + lastFailReason: { + type: String, + default: null, + }, + }, + { timestamps: true } +); + +// Used for scanning for claims +JobSchema.index({ type: 1, isActive: 1, nextScheduledAt: 1 }); +// for findById, upsertEvaluate, deleteById +JobSchema.index({ refId: 1, type: 1 }); + +const JobModel = model("Job", JobSchema); + +export { JobModel }; +export default JobModel; diff --git a/server/src/domain/jobs/job.repository.interface.ts b/server/src/domain/jobs/job.repository.interface.ts new file mode 100644 index 000000000..d253523fa --- /dev/null +++ b/server/src/domain/jobs/job.repository.interface.ts @@ -0,0 +1,76 @@ +import { Job, JobSeed, JobType } from "@/domain/jobs/job.type.js"; + +export type JobPageQuery = { + page?: number; + rowsPerPage?: number; +}; +export type JobPage = { + jobs: Job[]; + count: number; +}; + +export interface IJobsRepository { + // ******************** + // Claims + // ******************** + + // Atomically claim a job + claimDue(type: JobType, now: number): Promise; + + // locks up to `limit` due rows in one batch + claimDueBatch(type: JobType, limit: number, now: number): Promise; + + // Extends the locks we still hold. Returns how many were renewed + renewLocks(ids: string[], now: number): Promise; + + // Completes a repeating job + recordSuccess(id: string, nextScheduledAt: number, intervalMs: number, now: number): Promise; + + // Record failure, release lease, bump fail count, record reason, reset nextScheduledAt for retry + recordFailure(id: string, error: unknown, now: number): Promise; + + recordOneShot(id: string, now: number): Promise; + + // ******************** + // Hand off to evaluator // This is accomplished by creating an "evaluation" type job + // ******************** + upsertEvaluate(monitorId: string, now: number): Promise; + + // ******************** + // Job CRUD + // ******************** + + // Create jobs. Takes a JobSeed + upsertJob(job: JobSeed): Promise; + + // Creates cleanup jobs, these are executed immediately so need their own method + upsertCleanupJob(job: JobSeed): Promise; + + // Pause/Resume + setActiveById(refId: string, isActive: boolean): Promise; + + // Edit job schedule + updateScheduleById(refId: string, type: JobType, intervalMs: number | null): Promise; + + // Re-arm the check/geo-check rows. Add jitter to prevent herd + markMonitorsDue(monitorIds: string[], now: number): Promise; + + // Delete, drop all rows + deleteById(refId: string): Promise; + + // Delete a single job of type + deleteByIdAndType(refId: string, type: JobType): Promise; + + // Delete jobs that reference monitors that no longer exist + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; + + // Get job + findById(refId: string): Promise; + + // ******************** + // Observability + // ******************** + findPage(pagination: JobPageQuery): Promise; + findAll(): Promise; + countDueBacklog(now: number): Promise; // Scaling signal +} diff --git a/server/src/domain/jobs/job.repository.mongo.ts b/server/src/domain/jobs/job.repository.mongo.ts new file mode 100644 index 000000000..60bd4cd77 --- /dev/null +++ b/server/src/domain/jobs/job.repository.mongo.ts @@ -0,0 +1,315 @@ +import { IJobsRepository, JobPageQuery } from "@/domain/jobs/job.repository.interface.js"; +import JobModel, { JobDocument } from "@/domain/jobs/job.model.js"; +import { JobType, Job, JobSeed, BACKOFF_MS, LOCK_MS, PARKED, jobId } from "@/domain/jobs/job.type.js"; + +// Cap for rearm interval, never want to wait more than 15s. +const REARM_JITTER_MAX_MS = 15_000; + +class MongoJobsRepository implements IJobsRepository { + constructor(private readonly workerId: string) {} + + private toEntity(doc: JobDocument): Job { + return { + id: doc._id, + type: doc.type, + refId: doc.refId, + isActive: doc.isActive, + nextScheduledAt: doc.nextScheduledAt, + intervalMs: doc.intervalMs, + lockedBy: doc.lockedBy, + lockedUntil: doc.lockedUntil, + runCount: doc.runCount, + failCount: doc.failCount, + lastFinishedAt: doc.lastFinishedAt, + lastFailReason: doc.lastFailReason, + }; + } + + claimDue = async (type: JobType, now: number) => { + const doc = await JobModel.findOneAndUpdate( + { + type, + isActive: true, + nextScheduledAt: { $lte: now }, // Find due jobs + $or: [ + { lockedUntil: null }, // Not locked + { lockedUntil: { $lt: now } }, // Free or expired lock + ], + }, + { + $set: { + lockedBy: this.workerId, + lockedUntil: now + LOCK_MS, + }, + }, + { + new: true, + sort: { nextScheduledAt: 1 }, // Get the oldest due job + } + ).lean(); + return doc ? this.toEntity(doc) : null; + }; + + claimDueBatch = async (type: JobType, limit: number, now: number) => { + if (limit <= 0) return []; + const dueAndFree = { + type, + isActive: true, + nextScheduledAt: { $lte: now }, // due + $or: [{ lockedUntil: null }, { lockedUntil: { $lt: now } }], // free or expired lock + }; + + // 1. Pick the oldest-due candidates + const candidates = await JobModel.find(dueAndFree).sort({ nextScheduledAt: 1 }).limit(limit).select({ _id: 1 }).lean<{ _id: string }[]>(); + if (candidates.length === 0) return []; + const ids = candidates.map((d) => d._id); + + // 2. lock them. Re-check free/expired so we never steal another worker's live lock + // only the rows this updateMany actually flips end up with claimed + const lockedUntil = now + LOCK_MS; + await JobModel.updateMany( + { _id: { $in: ids }, $or: [{ lockedUntil: null }, { lockedUntil: { $lt: now } }] }, + { $set: { lockedBy: this.workerId, lockedUntil } } + ); + + // 3. Read back exactly the rows we now hold from this batch, keyed by the lockedUntil time + const docs = await JobModel.find({ _id: { $in: ids }, lockedBy: this.workerId, lockedUntil }).lean(); + return docs.map(this.toEntity); + }; + + renewLocks = async (ids: string[], now: number) => { + if (ids.length === 0) return 0; + const res = await JobModel.updateMany( + { _id: { $in: ids }, lockedBy: this.workerId }, // only locks held by this worker can be renewed + { $set: { lockedUntil: now + LOCK_MS } } + ); + return res.modifiedCount; + }; + + recordSuccess = async (id: string, scheduledRunAt: number, intervalMs: number, now: number) => { + // Fixed rate job scheduling + + let nextScheduledAt = scheduledRunAt + intervalMs; + + // If the job fell behind, skip the missed runs. Spread the next run with jitter to avoid a herd + if (nextScheduledAt <= now) { + nextScheduledAt = now + intervalMs + Math.floor(Math.random() * intervalMs); + } + + const res = await JobModel.updateOne( + { + _id: id, + lockedBy: this.workerId, // Only the worker that claimed can record success + }, + { + $set: { + nextScheduledAt, + lockedBy: null, // Remove lock + lockedUntil: null, + lastFinishedAt: now, + }, + $inc: { + runCount: 1, // Increment run count + }, + } + ); + return res.modifiedCount === 1; + }; + + recordFailure = async (id: string, error: unknown, now: number) => { + const res = await JobModel.updateOne( + { + _id: id, + lockedBy: this.workerId, // Only the worker that claimed can record success + }, + { + $set: { + nextScheduledAt: now + BACKOFF_MS, + lockedBy: null, // Remove lock + lockedUntil: null, + lastFinishedAt: now, // a failed run still finished used in getMetrics for failedAt timestamp + lastFailReason: error instanceof Error ? error.message : String(error), + }, + $inc: { + failCount: 1, // Increment fail count + }, + } + ); + return res.modifiedCount === 1; + }; + + recordOneShot = async (id: string, now: number) => { + const res = await JobModel.updateOne( + { + _id: id, + lockedBy: this.workerId, // Only the worker that claimed can record success + }, + { + $set: { + nextScheduledAt: PARKED, + lockedBy: null, // Remove lock + lockedUntil: null, + lastFinishedAt: now, + }, + $inc: { + runCount: 1, // Increment run count + }, + } + ); + return res.modifiedCount === 1; + }; + + upsertEvaluate = async (monitorId: string, now: number) => { + const filter = { type: "evaluate", refId: monitorId }; + const update = { + $setOnInsert: { + _id: jobId("evaluate", monitorId), + // refId and Type inferred from filter + isActive: true, + intervalMs: null, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + }, + $min: { + nextScheduledAt: now, // no op if there is already a pending evaluation that is older + }, + }; + try { + const res = await JobModel.updateOne(filter, update, { upsert: true }); + return res.acknowledged; + } catch (error) { + // Two callers can race the first insert for the same monitor, so the + // loser hits a duplicate _id. Retry once, the row now exists, so this becomes the $min. + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + const res = await JobModel.updateOne(filter, update, { upsert: true }); + return res.acknowledged; + } + throw error; + } + }; + upsertJob = async (job: JobSeed) => { + const res = await JobModel.updateOne( + { _id: job.id }, + { + $set: { + // These fields are owned by the monitor, always safe to set + type: job.type, + refId: job.refId, + isActive: job.isActive, + intervalMs: job.intervalMs, + }, + $setOnInsert: { + // These fields are scheduling related, only set on insert so they aren't clobbered + nextScheduledAt: job.nextScheduledAt, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + }, + }, + { upsert: true } + ); + return res.acknowledged; + }; + + upsertCleanupJob = async (job: JobSeed) => { + const res = await JobModel.updateOne( + { _id: job.id }, + { + $set: { + type: job.type, + refId: job.refId, + isActive: job.isActive, + intervalMs: job.intervalMs, + nextScheduledAt: job.nextScheduledAt, + }, + $setOnInsert: { + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + }, + }, + { upsert: true } + ); + return res.acknowledged; + }; + + setActiveById = async (refId: string, isActive: boolean) => { + const res = await JobModel.updateMany({ refId }, { $set: { isActive } }); // inverts check and geo check rows together + return res.modifiedCount > 0; + }; + + updateScheduleById = async (refId: string, type: JobType, intervalMs: number | null) => { + const res = await JobModel.updateOne({ refId, type }, { $set: { intervalMs } }); + return res.modifiedCount === 1; + }; + + markMonitorsDue = async (monitorIds: string[], now: number) => { + if (monitorIds.length === 0) return 0; + const res = await JobModel.updateMany({ refId: { $in: monitorIds }, type: { $in: ["check", "geo-check"] } }, [ + { + // Spread rearmed jobs, but never for more than rearm jitter max + $set: { + nextScheduledAt: { + $add: [now, { $floor: { $multiply: [{ $rand: {} }, { $min: ["$intervalMs", REARM_JITTER_MAX_MS] }] } }], + }, + }, + }, + ]); + return res.modifiedCount; + }; + + deleteById = async (refId: string) => { + const res = await JobModel.deleteMany({ refId }); // Delete all jobs for monitor + return res.deletedCount > 0; + }; + + deleteByIdAndType = async (refId: string, type: JobType) => { + const res = await JobModel.deleteOne({ refId, type }); // Delete a single typed row, e.g. just the geo row + return res.deletedCount > 0; + }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + // remove jobs who'se refId is not null (cleanup jobs) and not in the list of monitor IDs (orphaned monitor jobs) + const res = await JobModel.deleteMany({ refId: { $ne: null, $nin: monitorIds } }); + return res.deletedCount ?? 0; + }; + + findById = async (refId: string) => { + const docs = await JobModel.find({ refId }).lean(); + return docs.map(this.toEntity); + }; + + findPage = async ({ page = 0, rowsPerPage = 0 }: JobPageQuery) => { + const count = await JobModel.countDocuments(); + const query = JobModel.find().sort({ nextScheduledAt: 1 }); + if (rowsPerPage > 0) query.skip(Math.max(page, 0) * rowsPerPage).limit(rowsPerPage); + const docs = await query.lean(); + return { jobs: docs.map(this.toEntity), count }; + }; + + findAll = async () => { + const docs = await JobModel.find().lean(); + return docs.map(this.toEntity); + }; + + countDueBacklog = async (now: number) => { + return JobModel.countDocuments({ + type: "check", + isActive: true, + nextScheduledAt: { $lte: now }, + $or: [{ lockedUntil: null }, { lockedUntil: { $lt: now } }], // free or expired lock + }); + }; +} + +export default MongoJobsRepository; diff --git a/server/src/domain/jobs/job.type.ts b/server/src/domain/jobs/job.type.ts new file mode 100644 index 000000000..70d635f81 --- /dev/null +++ b/server/src/domain/jobs/job.type.ts @@ -0,0 +1,32 @@ +export const JOB_TYPES = ["check", "geo-check", "evaluate", "cleanup-orphaned", "cleanup-retention"] as const; +export type JobType = (typeof JOB_TYPES)[number]; +export const LOCK_MS = 60_000; +export const BACKOFF_MS = 5_000; +export const PARKED = Number.MAX_SAFE_INTEGER; + +// Canonical _id scheme: monitor-bound rows are `${type}:${refId}`; global rows (refId null) are just the type. +export const jobId = (type: JobType, refId: string | null): string => (refId === null ? type : `${type}:${refId}`); + +export type Job = { + id: string; + type: JobType; + refId: string | null; + isActive: boolean; + + // Scheduling fields + nextScheduledAt: number; // epoch ms; due when nextScheduledAt <= now + intervalMs: number | null; // repeat period in ms; null = one time job + + // Lock fields + lockedBy: string | null; // workerId holding the lease + lockedUntil: number | null; // epoch ms; lease expiry => reclaimable after a crash + + // observability (replaces what getMetrics/getJobs) + runCount: number; + failCount: number; + lastFinishedAt: number | null; + lastFailReason: string | null; +}; + +// These are the only values to set when creating a job. Repo owns the rest +export type JobSeed = Pick; diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/domain/maintenance-windows/maintenance-window.model.ts similarity index 91% rename from server/src/db/models/MaintenanceWindow.ts rename to server/src/domain/maintenance-windows/maintenance-window.model.ts index 654ae50dd..c42ef3fdc 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; +import { DurationUnits, type MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js"; type MaintenanceWindowDocumentBase = Omit & { monitorIds: Types.ObjectId[]; diff --git a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts b/server/src/domain/maintenance-windows/maintenance-window.repository.interface.ts similarity index 89% rename from server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts rename to server/src/domain/maintenance-windows/maintenance-window.repository.interface.ts index 0f4ad66a2..06384aed5 100644 --- a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.repository.interface.ts @@ -1,4 +1,4 @@ -import type { MaintenanceWindow } from "@/types/index.js"; +import type { MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js"; export interface IMaintenanceWindowsRepository { // create create(data: Partial): Promise; diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/domain/maintenance-windows/maintenance-window.repository.mongo.ts similarity index 94% rename from server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts rename to server/src/domain/maintenance-windows/maintenance-window.repository.mongo.ts index c3f94f55d..ce32a22d1 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.repository.mongo.ts @@ -1,6 +1,6 @@ -import type { MaintenanceWindow } from "@/types/index.js"; -import { type MaintenanceWindowDocument, MaintenanceWindowModel } from "@/db/models/index.js"; -import { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js"; +import type { MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js"; +import { type MaintenanceWindowDocument, MaintenanceWindowModel } from "@/domain/maintenance-windows/maintenance-window.model.js"; +import { IMaintenanceWindowsRepository } from "./maintenance-window.repository.interface.js"; import mongoose, { SortOrder } from "mongoose"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts similarity index 86% rename from server/src/service/business/maintenanceWindowService.ts rename to server/src/domain/maintenance-windows/maintenance-window.service.ts index 2ea4c4eae..5551aef62 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.service.ts @@ -1,7 +1,10 @@ -import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; -import type { DurationUnit, MaintenanceWindow } from "@/types/index.js"; +import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import type { DurationUnit, MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js"; import { AppError } from "@/utils/AppError.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; const SERVICE_NAME = "maintenanceWindowService"; @@ -39,16 +42,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { static SERVICE_NAME = SERVICE_NAME; private monitorsRepository: IMonitorsRepository; private maintenanceWindowsRepository: IMaintenanceWindowsRepository; + private jobsRepository: IJobsRepository; + private scheduler: IJobScheduler; constructor({ monitorsRepository, maintenanceWindowsRepository, + jobsRepository, + scheduler, }: { monitorsRepository: IMonitorsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; + jobsRepository: IJobsRepository; + scheduler: IJobScheduler; }) { this.monitorsRepository = monitorsRepository; this.maintenanceWindowsRepository = maintenanceWindowsRepository; + this.jobsRepository = jobsRepository; + this.scheduler = scheduler; } get serviceName() { @@ -70,6 +81,11 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId)); if (toInitializing.length > 0) { await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }, ["paused"]); + // Rearm jobs to run soon (now + jitter to avoid herding) + await this.jobsRepository.markMonitorsDue(toInitializing, now.getTime()); + // Wake the (possibly idle, backed-off) loops so the rearmed jobs run promptly instead of waiting up to POLL_MAX_MS + this.scheduler.wake("check"); + this.scheduler.wake("geo-check"); } }; diff --git a/server/src/types/maintenanceWindow.ts b/server/src/domain/maintenance-windows/maintenance-window.type.ts similarity index 100% rename from server/src/types/maintenanceWindow.ts rename to server/src/domain/maintenance-windows/maintenance-window.type.ts diff --git a/server/src/db/models/Migration.ts b/server/src/domain/migrations/migration.model.ts similarity index 100% rename from server/src/db/models/Migration.ts rename to server/src/domain/migrations/migration.model.ts diff --git a/server/src/db/models/MonitorStats.ts b/server/src/domain/monitor-stats/monitor-stats.model.ts similarity index 92% rename from server/src/db/models/MonitorStats.ts rename to server/src/domain/monitor-stats/monitor-stats.model.ts index 4ef2dab61..889b5ec60 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/domain/monitor-stats/monitor-stats.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { MonitorStats as MonitorStatsEntity } from "@/types/monitorStats.js"; +import type { MonitorStats as MonitorStatsEntity } from "@/domain/monitor-stats/monitor-stats.type.js"; type MonitorStatsDocumentBase = Omit & { monitorId: Types.ObjectId; diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/domain/monitor-stats/monitor-stats.repository.interface.ts similarity index 85% rename from server/src/repositories/monitor-stats/IMonitorStatsRepository.ts rename to server/src/domain/monitor-stats/monitor-stats.repository.interface.ts index 101283e32..286ef3608 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/domain/monitor-stats/monitor-stats.repository.interface.ts @@ -1,4 +1,4 @@ -import type { MonitorStats, CheckResultInput } from "@/types/index.js"; +import type { CheckResultInput, MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js"; export interface IMonitorStatsRepository { // create diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/domain/monitor-stats/monitor-stats.repository.mongo.ts similarity index 94% rename from server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts rename to server/src/domain/monitor-stats/monitor-stats.repository.mongo.ts index ea255f7ad..7027197da 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/domain/monitor-stats/monitor-stats.repository.mongo.ts @@ -1,6 +1,6 @@ -import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js"; -import type { MonitorStats, CheckResultInput } from "@/types/index.js"; -import { IMonitorStatsRepository } from "@/repositories/index.js"; +import { type MonitorStatsDocument, MonitorStatsModel } from "@/domain/monitor-stats/monitor-stats.model.js"; +import type { CheckResultInput, MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js"; +import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; class MongoMonitorStatsRepository implements IMonitorStatsRepository { diff --git a/server/src/types/monitorStats.ts b/server/src/domain/monitor-stats/monitor-stats.type.ts similarity index 100% rename from server/src/types/monitorStats.ts rename to server/src/domain/monitor-stats/monitor-stats.type.ts diff --git a/server/src/db/models/Monitor.ts b/server/src/domain/monitors/monitor.model.ts similarity index 94% rename from server/src/db/models/Monitor.ts rename to server/src/domain/monitors/monitor.model.ts index e6bffddd8..47aa70b5e 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; -import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js"; +import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js"; import type { CheckAudits, CheckCaptureInfo, @@ -12,7 +12,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, -} from "@/types/check.js"; +} from "@/domain/checks/check.type.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -34,6 +34,7 @@ interface MonitorDocument extends MonitorDocumentBase { teamId: Types.ObjectId; createdAt: Date; updatedAt: Date; + lastEvaluatedAt: number; // epoch ms } const snapshotTimingPhasesSchema = new Schema( @@ -196,7 +197,7 @@ const checkSnapshotSchema = new Schema( audits: { type: snapshotAuditsSchema }, createdAt: { type: Date, required: true }, }, - { _id: false } + { _id: false, suppressReservedKeysWarning: true } ); const MonitorSchema = new Schema( @@ -220,6 +221,12 @@ const MonitorSchema = new Schema( description: { type: String, }, + method: { + type: String, + enum: HttpMethods, + default: "GET", + required: true, + }, status: { type: String, enum: MonitorStatuses, @@ -291,6 +298,10 @@ const MonitorSchema = new Schema( ref: "Tag", }, ], + customUpCodes: { + type: [Number], + default: [], + }, secret: { type: String, }, @@ -373,6 +384,10 @@ const MonitorSchema = new Schema( type: [checkSnapshotSchema], default: [], }, + lastEvaluatedAt: { + type: Number, + default: 0, + }, }, { timestamps: true, diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/domain/monitors/monitor.repository.interface.ts similarity index 87% rename from server/src/repositories/monitors/IMonitorsRepository.ts rename to server/src/domain/monitors/monitor.repository.interface.ts index 90fcc5a94..46f3df396 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -1,4 +1,4 @@ -import { type MonitorType, type Monitor, type MonitorStatus, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.types.js"; export interface TeamQueryConfig { limit?: number; @@ -22,10 +22,13 @@ export interface IMonitorsRepository { createMonitors(monitors: Monitor[]): Promise; // single fetch findById(monitorId: string, teamId: string): Promise; + // for workers, don't need all details + findByIdLean(monitorId: string): Promise; // collection fetch findAll(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig): Promise; + findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/domain/monitors/monitor.repository.mongo.ts similarity index 86% rename from server/src/repositories/monitors/MongoMonitorsRepository.ts rename to server/src/domain/monitors/monitor.repository.mongo.ts index 3d1f9c3a5..cba542c96 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -1,11 +1,11 @@ -import { MonitorModel } from "@/db/models/index.js"; -import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js"; -import type { Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import { MonitorModel } from "@/domain/monitors/monitor.model.js"; +import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js"; +import type { CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.types.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; -import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; - +import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; class MongoMonitorsRepository implements IMonitorsRepository { create = async (monitor: Monitor, teamId: string, userId: string) => { const monitorModel = new MonitorModel({ ...monitor, teamId, userId }); @@ -38,6 +38,11 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(monitor); }; + findByIdLean = async (monitorId: string): Promise => { + const monitor = await MonitorModel.findOne({ _id: monitorId }); + return monitor ? this.toEntity(monitor) : null; + }; + findAll = async (): Promise => { const monitors = await MonitorModel.find(); return this.mapDocuments(monitors); @@ -85,10 +90,102 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.mapDocuments(documents); }; + findByTeamIdWithStats = async (teamId: string, config: TeamQueryConfig): Promise => { + const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {}; + + const query: Record = { + teamId: new mongoose.Types.ObjectId(teamId), + }; + + if (type !== undefined) { + query.type = Array.isArray(type) ? { $in: type } : type; + } + + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + + if (filter !== undefined) { + switch (field) { + case "name": + query.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }]; + break; + case "isActive": + query.isActive = filter === "true"; + break; + case "status": + query.status = filter; + break; + case "type": + query.type = filter; + break; + default: + break; + } + } + + const sort = { [field]: order === "asc" ? 1 : -1 } as const; + const skip = Math.max(page, 0) * rowsPerPage; + + const pipeline: PipelineStage[] = [ + { $match: query }, + { $sort: sort }, + ...(rowsPerPage ? [{ $skip: skip }, { $limit: rowsPerPage }] : []), + { + $lookup: { + from: "monitorstats", + localField: "_id", + foreignField: "monitorId", + as: "stats", + }, + }, + { + $addFields: { + uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] }, + }, + }, + { + $project: { + stats: 0, + }, + }, + ]; + + const documents = await MonitorModel.aggregate(pipeline); + return documents.map((doc) => this.toEntity(doc)); + }; + findByIds = async (monitorIds: string[]): Promise => { + if (!monitorIds.length) { + return []; + } + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const monitors = await MonitorModel.find({ _id: { $in: objectIds } }); - return this.mapDocuments(monitors); + + const pipeline: PipelineStage[] = [ + { $match: { _id: { $in: objectIds } } }, + { + $lookup: { + from: "monitorstats", + localField: "_id", + foreignField: "monitorId", + as: "stats", + }, + }, + { + $addFields: { + uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] }, + }, + }, + { + $project: { + stats: 0, + }, + }, + ]; + + const documents = await MonitorModel.aggregate(pipeline); + return documents.map((doc) => this.toEntity(doc)); }; findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { @@ -439,6 +536,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, + method: doc.method ?? "GET", status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, @@ -456,6 +554,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, tags: tagIds, + customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -478,6 +577,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), + lastEvaluatedAt: doc.lastEvaluatedAt, }; }; @@ -503,6 +603,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { teamId: toStringId(doc.teamId), name: doc.name, description: doc.description ?? undefined, + method: doc.method ?? "GET", status: doc.status ?? "initializing", statusWindow: doc.statusWindow ?? [], statusWindowSize: doc.statusWindowSize, @@ -520,6 +621,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, tags: tagIds, + customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -542,6 +644,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), + lastEvaluatedAt: doc.lastEvaluatedAt, }; }; diff --git a/server/src/service/business/monitorService.ts b/server/src/domain/monitors/monitor.service.ts similarity index 90% rename from server/src/service/business/monitorService.ts rename to server/src/domain/monitors/monitor.service.ts index 46b81a2ca..455a1daff 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -1,5 +1,5 @@ import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js"; -import { type Monitor } from "@/types/index.js"; +import { type Monitor } from "@/domain/monitors/monitor.types.js"; import type { MonitorType, MonitorsWithChecksByTeamIdResult, @@ -8,23 +8,21 @@ import type { PageSpeedDetailsResult, GamesMap, GroupedGeoCheckResult, -} from "@/types/monitor.js"; -import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js"; -import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js"; -import type { GeoContinent } from "@/types/geoCheck.js"; -import type { - IChecksRepository, - IGeoChecksRepository, - IIncidentsRepository, - IMonitorsRepository, - IMonitorStatsRepository, - IStatusPagesRepository, -} from "@/repositories/index.js"; +} from "@/domain/monitors/monitor.types.js"; +import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.types.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js"; +import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; +import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; +import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import type { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; +import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.js"; -import type { ImportedMonitor } from "@/validation/monitorValidation.js"; -import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; +import type { ImportedMonitor } from "@/api/validation/monitorValidation.js"; import { ILogger } from "@/utils/logger.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; const SERVICE_NAME = "MonitorService"; type DateRangeKey = "recent" | "day" | "week" | "month" | "all"; @@ -96,7 +94,7 @@ export interface IMonitorService { export class MonitorService implements IMonitorService { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: IJobQueue; + private scheduler: IJobScheduler; private logger: ILogger; private games: GamesMap; private monitorsRepository: IMonitorsRepository; @@ -107,7 +105,7 @@ export class MonitorService implements IMonitorService { private incidentsRepository: IIncidentsRepository; constructor({ - jobQueue, + scheduler, logger, games, monitorsRepository, @@ -117,7 +115,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository, incidentsRepository, }: { - jobQueue: IJobQueue; + scheduler: IJobScheduler; logger: ILogger; games: GamesMap; monitorsRepository: IMonitorsRepository; @@ -127,7 +125,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; }) { - this.jobQueue = jobQueue; + this.scheduler = scheduler; this.logger = logger; this.games = games; this.monitorsRepository = monitorsRepository; @@ -173,7 +171,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" }); } - this.jobQueue.addJob(monitor.id, monitor); + this.scheduler.addJob(monitor.id, monitor); }; createMonitors = async (monitors: Array): Promise => { @@ -182,7 +180,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" }); } - await Promise.all(createdMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); + await Promise.all(createdMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor))); return createdMonitors; }; @@ -198,7 +196,7 @@ export class MonitorService implements IMonitorService { })); const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]); - await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor))); + await Promise.all(demoMonitors.map((monitor) => this.scheduler.addJob(monitor.id, monitor))); return demoMonitors; }; @@ -393,7 +391,7 @@ export class MonitorService implements IMonitorService { }): Promise => { const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type, tags }); const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter }); - const monitors = await this.monitorsRepository.findByTeamId(teamId, { + const monitors = await this.monitorsRepository.findByTeamIdWithStats(teamId, { limit, type, tags, @@ -430,7 +428,7 @@ export class MonitorService implements IMonitorService { editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial }) => { const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body); - await this.jobQueue.updateJob(editedMonitor); + await this.scheduler.updateJob(editedMonitor); return editedMonitor; }; @@ -450,7 +448,7 @@ export class MonitorService implements IMonitorService { // If notifications were updated, we should update the jobs in the queue if (modifiedCount > 0) { const monitors = await this.monitorsRepository.findByIds(monitorIds); - await Promise.all(monitors.map((monitor) => this.jobQueue.updateJob(monitor))); + await Promise.all(monitors.map((monitor) => this.scheduler.updateJob(monitor))); } return modifiedCount; @@ -459,9 +457,9 @@ export class MonitorService implements IMonitorService { pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId); if (monitor.isActive) { - await this.jobQueue.resumeJob(monitor); + await this.scheduler.resumeJob(monitor); } else { - await this.jobQueue.pauseJob(monitor); + await this.scheduler.pauseJob(monitor); } return monitor; }; @@ -480,9 +478,9 @@ export class MonitorService implements IMonitorService { const results = await Promise.allSettled( monitors.map(async (monitor) => { if (monitor.isActive) { - await this.jobQueue.resumeJob(monitor); + await this.scheduler.resumeJob(monitor); } else { - await this.jobQueue.pauseJob(monitor); + await this.scheduler.pauseJob(monitor); } }) ); @@ -543,7 +541,7 @@ export class MonitorService implements IMonitorService { }); }); - await this.jobQueue.deleteJob(monitor); + await this.scheduler.deleteJob(monitor); return monitor; }; @@ -552,7 +550,7 @@ export class MonitorService implements IMonitorService { await Promise.all( monitors.map(async (monitor) => { try { - await this.jobQueue.deleteJob(monitor); + await this.scheduler.deleteJob(monitor); await this.checksRepository.deleteByMonitorId(monitor.id); await this.geoChecksRepository.deleteByMonitorId(monitor.id); await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id); @@ -599,6 +597,7 @@ export class MonitorService implements IMonitorService { recentChecks: [], createdAt: "", updatedAt: "", + lastEvaluatedAt: 0, })); const createdMonitors = await this.createMonitors(cleanedMonitors); diff --git a/server/src/types/monitor.ts b/server/src/domain/monitors/monitor.types.ts similarity index 68% rename from server/src/types/monitor.ts rename to server/src/domain/monitors/monitor.types.ts index 0708297f3..22090f68c 100644 --- a/server/src/types/monitor.ts +++ b/server/src/domain/monitors/monitor.types.ts @@ -1,7 +1,34 @@ -import type { CheckSnapshot } from "@/types/check.js"; -export type { CheckSnapshot } from "@/types/check.js"; -import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js"; -export type { GeoContinent } from "@/types/geoCheck.js"; +import type { CheckSnapshot } from "@/domain/checks/check.type.js"; +export type { CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; +export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; +import http from "node:http"; + +export const HttpStatusCodes = [ + ...Object.keys(http.STATUS_CODES).map(Number), + 419, // Page Expired (Laravel) + 420, // Enhance Your Calm (Twitter) + 440, // Login Time-out (IIS) + 449, // Retry With (IIS) + 460, // Client Closed Connection (AWS ELB) + 463, // X-Forwarded-For Too Large (AWS ELB) + 497, // HTTP Request Sent to HTTPS Port (NGINX) + 499, // Client Closed Request (NGINX) + 509, // Bandwidth Limit Exceeded (Apache) + 520, // Web Server Returned an Unknown Error (Cloudflare) + 521, // Web Server Is Down (Cloudflare) + 522, // Connection Timed Out (Cloudflare) + 523, // Origin Is Unreachable (Cloudflare) + 524, // A Timeout Occurred (Cloudflare) + 525, // SSL Handshake Failed (Cloudflare) + 526, // Invalid SSL Certificate (Cloudflare) + 527, // Railgun Error (Cloudflare) + 529, // Site is overloaded + 530, // Site is frozen (Cloudflare) + 561, // Unauthorized (AWS ELB) +]; +export const HttpStatusCodeSet = new Set(HttpStatusCodes); +export type HttpStatusCode = number; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; @@ -35,6 +62,9 @@ export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; +export const HttpMethods = ["GET", "HEAD"] as const; +export type HttpMethod = (typeof HttpMethods)[number]; + export const MAX_RECENT_CHECKS = 50; export interface Monitor { @@ -43,6 +73,7 @@ export interface Monitor { teamId: string; name: string; description?: string; + method: HttpMethod; status: MonitorStatus; statusWindow: boolean[]; statusWindowSize: number; @@ -60,6 +91,7 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; tags: string[]; + customUpCodes: HttpStatusCode[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; @@ -82,6 +114,7 @@ export interface Monitor { recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; + lastEvaluatedAt: number; // epoch ms } export interface MonitorsSummary { @@ -107,13 +140,13 @@ export interface GroupedGeoCheckResult { export interface UptimeDetailsResult { monitorData: { monitor: Monitor; - groupedChecks: import("./check.js").GroupedCheck[]; - groupedUpChecks: import("./check.js").GroupedCheck[]; - groupedDownChecks: import("./check.js").GroupedCheck[]; + groupedChecks: import("../checks/check.type.js").GroupedCheck[]; + groupedUpChecks: import("../checks/check.type.js").GroupedCheck[]; + groupedDownChecks: import("../checks/check.type.js").GroupedCheck[]; groupedAvgResponseTime: number; groupedUptimePercentage: number; }; - monitorStats: import("./monitorStats.js").MonitorStats | null; + monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; } export interface HardwareDiskStats { @@ -161,15 +194,15 @@ export interface HardwareStats { export interface HardwareDetailsResult { monitor: Monitor; stats: HardwareStats; - monitorStats: import("./monitorStats.js").MonitorStats | null; + monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; } export interface PageSpeedDetailsResult { monitorData: { monitor: Monitor; - groupedChecks: import("./check.js").PageSpeedGroupedCheck[]; + groupedChecks: import("../checks/check.type.js").PageSpeedGroupedCheck[]; }; - monitorStats: import("./monitorStats.js").MonitorStats | null; + monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; } export interface Game { diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/domain/notifications/notification.message-builder.ts similarity index 96% rename from server/src/service/infrastructure/notificationMessageBuilder.ts rename to server/src/domain/notifications/notification.message-builder.ts index bc1d7ac82..b11d99a94 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/domain/notifications/notification.message-builder.ts @@ -1,12 +1,13 @@ -import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; +import type { Monitor } from "@/domain/monitors/monitor.types.js"; +import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; import type { NotificationMessage, NotificationType, NotificationSeverity, ThresholdBreach, NotificationContent, -} from "@/types/notificationMessage.js"; +} from "@/domain/notifications/notification.type.js"; export interface INotificationMessageBuilder { buildMessage( @@ -186,7 +187,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { const breaches: ThresholdBreach[] = []; // Check if this is a hardware monitor with threshold data - if (monitor.type !== "hardware" || !monitorStatusResponse.payload) { + if (monitor.type !== "hardware" || !monitorStatusResponse.payload || typeof monitorStatusResponse.payload === "string") { return breaches; } diff --git a/server/src/db/models/Notification.ts b/server/src/domain/notifications/notification.model.ts similarity index 92% rename from server/src/db/models/Notification.ts rename to server/src/domain/notifications/notification.model.ts index 93202bb8f..797b3e768 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { Notification, NotificationChannel } from "@/types/notification.js"; +import type { Notification, NotificationChannel } from "@/domain/notifications/notification.type.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; diff --git a/server/src/repositories/notifications/INotificationsRepository.ts b/server/src/domain/notifications/notification.repository.interface.ts similarity index 86% rename from server/src/repositories/notifications/INotificationsRepository.ts rename to server/src/domain/notifications/notification.repository.interface.ts index 0236c3a89..9de6e1e06 100644 --- a/server/src/repositories/notifications/INotificationsRepository.ts +++ b/server/src/domain/notifications/notification.repository.interface.ts @@ -1,4 +1,4 @@ -import type { Notification } from "@/types/index.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; export interface INotificationsRepository { // create create(notificationData: Partial): Promise; diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/domain/notifications/notification.repository.mongo.ts similarity index 93% rename from server/src/repositories/notifications/MongoNotificationsRepository.ts rename to server/src/domain/notifications/notification.repository.mongo.ts index e556fa206..cc2729cf7 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/domain/notifications/notification.repository.mongo.ts @@ -1,7 +1,7 @@ import mongoose from "mongoose"; -import { NotificationModel, type NotificationDocument } from "@/db/models/index.js"; -import { INotificationsRepository } from "@/repositories/index.js"; -import type { Notification } from "@/types/index.js"; +import { NotificationModel, type NotificationDocument } from "@/domain/notifications/notification.model.js"; +import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; import { AppError } from "@/utils/AppError.js"; class MongoNotificationsRepository implements INotificationsRepository { diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/domain/notifications/notification.service.ts similarity index 91% rename from server/src/service/infrastructure/notificationsService.ts rename to server/src/domain/notifications/notification.service.ts index 09129a8af..7fc6e3543 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/domain/notifications/notification.service.ts @@ -1,11 +1,14 @@ -import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; -import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; -import type { ISettingsService } from "@/service/system/settingsService.js"; +import type { Monitor } from "@/domain/monitors/monitor.types.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import type { MonitorStatusResponse } from "@/types/network.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js"; +import { INotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { MonitorActionDecision } from "@/worker/worker.helper.js"; +import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; -import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; +import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; export interface INotificationsService { createNotification: (notificationData: Partial, userId: string, teamId: string) => Promise; diff --git a/server/src/types/notificationMessage.ts b/server/src/domain/notifications/notification.type.ts similarity index 51% rename from server/src/types/notificationMessage.ts rename to server/src/domain/notifications/notification.type.ts index f06ff1bd9..8ae617a88 100644 --- a/server/src/types/notificationMessage.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -1,3 +1,62 @@ +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", + "ntfy", +] as const; +export type NotificationChannel = (typeof NotificationChannels)[number]; + +export interface Notification { + id: string; + userId: string; + teamId: string; + type: NotificationChannel; + notificationName: string; + address?: string; + phone?: string; + homeserverUrl?: string; + roomId?: string; + accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; + topic?: string; + createdAt: string; + updatedAt: string; +} + +export interface AlertPagerDutyPayload { + routing_key?: string; + dedup_key?: string; + event_action?: "trigger" | "resolve"; + payload: Record; +} + +export interface AlertMatrixPayload { + plainText: string; + htmlText: string; +} + +export interface DiscordEmbedField { + name: string; + value: string; + inline?: boolean; +} + +export interface AlertDiscordPayload { + title: string; + description: string; + color: number; + fields: DiscordEmbedField[]; + timestamp: string; +} + /** * Unified notification message types for cross-provider consistency * Part of notification system unification effort diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/domain/notifications/providers/INotificationProvider.ts similarity index 85% rename from server/src/service/infrastructure/notificationProviders/INotificationProvider.ts rename to server/src/domain/notifications/providers/INotificationProvider.ts index b5c886408..6e1840669 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/domain/notifications/providers/INotificationProvider.ts @@ -1,5 +1,5 @@ -import type { Notification } from "@/types/index.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; import { ILogger } from "@/utils/logger.js"; import type { OptionsOfJSONResponseBody } from "got"; diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/domain/notifications/providers/discord.ts similarity index 92% rename from server/src/service/infrastructure/notificationProviders/discord.ts rename to server/src/domain/notifications/providers/discord.ts index 942e0b578..cada9fcf2 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/domain/notifications/providers/discord.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "DiscordProvider"; -import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; -import type { NotificationMessage, NotificationSeverity } from "@/types/notificationMessage.js"; +import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import type { NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js"; import got from "got"; export class DiscordProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/domain/notifications/providers/email.ts similarity index 89% rename from server/src/service/infrastructure/notificationProviders/email.ts rename to server/src/domain/notifications/providers/email.ts index 0890eac7f..fa84be66d 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/domain/notifications/providers/email.ts @@ -1,10 +1,10 @@ const SERVICE_NAME = "EmailProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { buildTestEmail } from "@/domain/notifications/providers/utils.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; import type { ILogger } from "@/utils/logger.js"; -import { IEmailService } from "@/service/infrastructure/emailService.js"; +import { IEmailService } from "@/service/emailService.js"; export class EmailProvider extends NotificationProvider { private emailService: IEmailService; diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/domain/notifications/providers/matrix.ts similarity index 93% rename from server/src/service/infrastructure/notificationProviders/matrix.ts rename to server/src/domain/notifications/providers/matrix.ts index f8a093ab7..a1f35b0ca 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/domain/notifications/providers/matrix.ts @@ -1,9 +1,9 @@ const SERVICE_NAME = "MatrixProvider"; import got from "got"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { AlertMatrixPayload, Notification } from "@/types/index.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { AlertMatrixPayload, Notification } from "@/domain/notifications/notification.type.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import { randomUUID } from "crypto"; export class MatrixProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/ntfy.ts b/server/src/domain/notifications/providers/ntfy.ts similarity index 90% rename from server/src/service/infrastructure/notificationProviders/ntfy.ts rename to server/src/domain/notifications/providers/ntfy.ts index 8811cce47..7ca351d87 100644 --- a/server/src/service/infrastructure/notificationProviders/ntfy.ts +++ b/server/src/domain/notifications/providers/ntfy.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "NtfyProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; export class NtfyProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/domain/notifications/providers/pagerduty.ts similarity index 89% rename from server/src/service/infrastructure/notificationProviders/pagerduty.ts rename to server/src/domain/notifications/providers/pagerduty.ts index 0d2fa75be..6cb264c1d 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/domain/notifications/providers/pagerduty.ts @@ -1,10 +1,10 @@ const SERVICE_NAME = "PagerDutyProvider"; import got from "got"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; -import { AlertPagerDutyPayload } from "@/types/index.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import { AlertPagerDutyPayload } from "@/domain/notifications/notification.type.js"; export class PagerDutyProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { diff --git a/server/src/service/infrastructure/notificationProviders/pushover.ts b/server/src/domain/notifications/providers/pushover.ts similarity index 89% rename from server/src/service/infrastructure/notificationProviders/pushover.ts rename to server/src/domain/notifications/providers/pushover.ts index 5d001892d..b1831e405 100644 --- a/server/src/service/infrastructure/notificationProviders/pushover.ts +++ b/server/src/domain/notifications/providers/pushover.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "PushoverProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; export class PushoverProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/domain/notifications/providers/slack.ts similarity index 92% rename from server/src/service/infrastructure/notificationProviders/slack.ts rename to server/src/domain/notifications/providers/slack.ts index 1ab046f7f..23f9b2f61 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/domain/notifications/providers/slack.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "SlackProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got, { HTTPError } from "got"; export class SlackProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/teams.ts b/server/src/domain/notifications/providers/teams.ts similarity index 94% rename from server/src/service/infrastructure/notificationProviders/teams.ts rename to server/src/domain/notifications/providers/teams.ts index f98724295..36d37a210 100644 --- a/server/src/service/infrastructure/notificationProviders/teams.ts +++ b/server/src/domain/notifications/providers/teams.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "TeamsProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got, { HTTPError } from "got"; // Types for Adaptive Card elements diff --git a/server/src/service/infrastructure/notificationProviders/telegram.ts b/server/src/domain/notifications/providers/telegram.ts similarity index 89% rename from server/src/service/infrastructure/notificationProviders/telegram.ts rename to server/src/domain/notifications/providers/telegram.ts index 9d6968fdd..72e31b5bf 100644 --- a/server/src/service/infrastructure/notificationProviders/telegram.ts +++ b/server/src/domain/notifications/providers/telegram.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "TelegramProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; export class TelegramProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/domain/notifications/providers/twilio.ts similarity index 88% rename from server/src/service/infrastructure/notificationProviders/twilio.ts rename to server/src/domain/notifications/providers/twilio.ts index a871fe5cb..2f7937b7f 100644 --- a/server/src/service/infrastructure/notificationProviders/twilio.ts +++ b/server/src/domain/notifications/providers/twilio.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "TwilioProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; export class TwilioProvider extends NotificationProvider { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/domain/notifications/providers/utils.ts similarity index 80% rename from server/src/service/infrastructure/notificationProviders/utils.ts rename to server/src/domain/notifications/providers/utils.ts index 454df1963..3eaf428fe 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/domain/notifications/providers/utils.ts @@ -1,4 +1,4 @@ -import { IEmailService } from "@/service/infrastructure/emailService.js"; +import { IEmailService } from "@/service/emailService.js"; export const buildTestEmail = async (emailService: IEmailService) => { const context = { testName: "Monitoring System" }; const html = await emailService.buildEmail("testEmailTemplate", context); diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/domain/notifications/providers/webhook.ts similarity index 90% rename from server/src/service/infrastructure/notificationProviders/webhook.ts rename to server/src/domain/notifications/providers/webhook.ts index 292650f91..fc5a80fde 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/domain/notifications/providers/webhook.ts @@ -1,8 +1,8 @@ const SERVICE_NAME = "WebhookProvider"; -import type { Notification } from "@/types/index.js"; -import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -import type { NotificationMessage } from "@/types/notificationMessage.js"; -import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { Notification } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import type { NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; export class WebhookProvider extends NotificationProvider { diff --git a/server/src/domain/queue-workers/queue-worker.model.ts b/server/src/domain/queue-workers/queue-worker.model.ts new file mode 100644 index 000000000..0dd90b5c5 --- /dev/null +++ b/server/src/domain/queue-workers/queue-worker.model.ts @@ -0,0 +1,23 @@ +import { Schema, model } from "mongoose"; +import { QueueModes } from "@/domain/app-settings/app-settings.type.js"; +import { QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.type.js"; + +// Single source of truth for worker liveness: the TTL that GCs stale records and +// the window readers use to count a worker as alive. Heartbeat cadence must stay well below it. +export const WORKER_TTL_SECONDS = 180; + +const QueueWorkerSchema = new Schema( + { + _id: { type: String, required: true }, + mode: { type: String, enum: QueueModes, required: true }, + processesJobs: { type: Boolean, required: true }, + lastSeenAt: { type: Date, default: Date.now, expires: WORKER_TTL_SECONDS }, + }, + { timestamps: true } +); + +const QueueWorkerModel = model("QueueWorker", QueueWorkerSchema); + +export type { QueueWorkerDocument }; +export { QueueWorkerModel }; +export default QueueWorkerModel; diff --git a/server/src/domain/queue-workers/queue-worker.repository.interface.ts b/server/src/domain/queue-workers/queue-worker.repository.interface.ts new file mode 100644 index 000000000..4da6f736e --- /dev/null +++ b/server/src/domain/queue-workers/queue-worker.repository.interface.ts @@ -0,0 +1,8 @@ +import type { QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import type { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js"; + +export interface IQueueWorkersRepository { + upsert(workerId: string, mode: QueueMode, processesJobs: boolean): Promise; + findRecent(maxAgeMs: number): Promise; + deleteById(workerId: string): Promise; +} diff --git a/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts b/server/src/domain/queue-workers/queue-worker.repository.mongo.ts similarity index 59% rename from server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts rename to server/src/domain/queue-workers/queue-worker.repository.mongo.ts index b8155ea1d..da62d9850 100644 --- a/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts +++ b/server/src/domain/queue-workers/queue-worker.repository.mongo.ts @@ -1,7 +1,7 @@ -import type { QueueMode, QueueWorker } from "@/types/settings.js"; -import type { IQueueWorkersRepository } from "./IQueueWorkersRepository.js"; -import type { QueueWorkerDocument } from "@/db/models/QueueWorker.js"; -import { QueueWorkerModel } from "@/db/models/QueueWorker.js"; +import type { QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import type { IQueueWorkersRepository } from "./queue-worker.repository.interface.js"; +import type { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js"; +import { QueueWorkerModel, type QueueWorkerDocument } from "@/domain/queue-workers/queue-worker.model.js"; const SERVICE_NAME = "MongoQueueWorkersRepository"; class MongoQueueWorkersRepository implements IQueueWorkersRepository { @@ -11,12 +11,13 @@ class MongoQueueWorkersRepository implements IQueueWorkersRepository { return { workerId: doc._id, mode: doc.mode, + processesJobs: doc.processesJobs, lastSeenAt: doc.lastSeenAt.getTime(), }; }; - upsert = async (workerId: string, mode: QueueMode): Promise => { - await QueueWorkerModel.updateOne({ _id: workerId }, { $set: { mode, lastSeenAt: new Date() } }, { upsert: true }); + upsert = async (workerId: string, mode: QueueMode, processesJobs: boolean): Promise => { + await QueueWorkerModel.updateOne({ _id: workerId }, { $set: { mode, processesJobs, lastSeenAt: new Date() } }, { upsert: true }); }; findRecent = async (maxAgeMs: number): Promise => { diff --git a/server/src/domain/queue-workers/queue-worker.type.ts b/server/src/domain/queue-workers/queue-worker.type.ts new file mode 100644 index 000000000..b750f77d7 --- /dev/null +++ b/server/src/domain/queue-workers/queue-worker.type.ts @@ -0,0 +1,17 @@ +import { type QueueMode } from "@/domain/app-settings/app-settings.type.js"; + +export interface QueueWorkerDocument { + _id: string; + mode: QueueMode; + processesJobs: boolean; + lastSeenAt: Date; + createdAt: Date; + updatedAt: Date; +} + +export type QueueWorker = { + workerId: string; // hostname:pid:uuid + mode: QueueMode; + processesJobs: boolean; + lastSeenAt: number; // epoch ms of the last heartbeat +}; diff --git a/server/src/db/models/RecoveryToken.ts b/server/src/domain/recovery-tokens/recovery-token.model.ts similarity index 87% rename from server/src/db/models/RecoveryToken.ts rename to server/src/domain/recovery-tokens/recovery-token.model.ts index 07b697d9e..54d77a4f2 100644 --- a/server/src/db/models/RecoveryToken.ts +++ b/server/src/domain/recovery-tokens/recovery-token.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { RecoveryToken as RecoveryTokenEntity } from "@/types/recoveryToken.js"; +import type { RecoveryToken as RecoveryTokenEntity } from "@/domain/recovery-tokens/recovery-token.type.js"; type RecoveryTokenDocumentBase = Omit & { expiry: Date; diff --git a/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts b/server/src/domain/recovery-tokens/recovery-token.repository.interface.ts similarity index 74% rename from server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts rename to server/src/domain/recovery-tokens/recovery-token.repository.interface.ts index 9df1fa793..d5f3a30b7 100644 --- a/server/src/repositories/recovery-tokens/IRecoveryTokensRepository.ts +++ b/server/src/domain/recovery-tokens/recovery-token.repository.interface.ts @@ -1,4 +1,4 @@ -import type { RecoveryToken } from "@/types/recoveryToken.js"; +import type { RecoveryToken } from "@/domain/recovery-tokens/recovery-token.type.js"; export interface IRecoveryTokensRepository { // create diff --git a/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts b/server/src/domain/recovery-tokens/recovery-token.repository.mongo.ts similarity index 82% rename from server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts rename to server/src/domain/recovery-tokens/recovery-token.repository.mongo.ts index 4c9f9ebb6..1e6140a88 100644 --- a/server/src/repositories/recovery-tokens/MongoRecoveryTokensRepository.ts +++ b/server/src/domain/recovery-tokens/recovery-token.repository.mongo.ts @@ -1,7 +1,7 @@ -import type { RecoveryToken } from "@/types/index.js"; -import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; -import type { RecoveryTokenDocument } from "@/db/models/RecoveryToken.js"; -import { RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; +import type { RecoveryToken } from "@/domain/recovery-tokens/recovery-token.type.js"; +import type { IRecoveryTokensRepository } from "./recovery-token.repository.interface.js"; +import type { RecoveryTokenDocument } from "@/domain/recovery-tokens/recovery-token.model.js"; +import { RecoveryTokenModel } from "@/domain/recovery-tokens/recovery-token.model.js"; import mongoose from "mongoose"; import crypto from "crypto"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/types/recoveryToken.ts b/server/src/domain/recovery-tokens/recovery-token.type.ts similarity index 100% rename from server/src/types/recoveryToken.ts rename to server/src/domain/recovery-tokens/recovery-token.type.ts diff --git a/server/src/repositories/status-pages/IStatusPagesRepository.ts b/server/src/domain/status-pages/status-page-repository.interface.ts similarity index 81% rename from server/src/repositories/status-pages/IStatusPagesRepository.ts rename to server/src/domain/status-pages/status-page-repository.interface.ts index c901b3c6d..4b3eccd0a 100644 --- a/server/src/repositories/status-pages/IStatusPagesRepository.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -1,10 +1,11 @@ -import type { StatusPage } from "@/types/statusPage.js"; +import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; export interface IStatusPagesRepository { // create create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // single fetch findByUrl(url: string): Promise; + findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch // update diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts similarity index 72% rename from server/src/repositories/status-pages/MongoStatusPagesRepository.ts rename to server/src/domain/status-pages/status-page-repository.mongo.ts index 3f4630eb1..2dedca4ef 100644 --- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -1,8 +1,9 @@ -import { IStatusPagesRepository } from "@/repositories/index.js"; -import { type StatusPageDocument, StatusPageModel } from "@/db/models/StatusPage.js"; -import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/types/statusPage.js"; +import { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { type StatusPageDocument, StatusPageModel } from "@/domain/status-pages/status-page.model.js"; +import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { @@ -47,6 +48,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { type: doc.type, companyName: doc.companyName, url: doc.url, + customDomain: doc.customDomain ?? null, timezone: doc.timezone ?? undefined, color: doc.color, monitors: this.mapIdArray(doc.monitors), @@ -74,10 +76,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }; create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const { logo, ...restData } = data; + const { logo, customDomain, ...restData } = data; void logo; + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); const statusPage = new StatusPageModel({ ...restData, + ...(normalizedCustomDomain ? { customDomain: normalizedCustomDomain } : {}), userId, teamId, }); @@ -102,6 +106,21 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { // Get status page }; + findByCustomDomain = async (customDomain: string): Promise => { + const normalizedDomain = normalizeStatusPageDomain(customDomain); + if (!normalizedDomain) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + + const statusPage = await StatusPageModel.findOne({ + customDomain: normalizedDomain, + }); + if (!statusPage) { + throw new AppError({ message: "Status page not found", status: 404 }); + } + return this.toEntity(statusPage); + }; + findByTeamId = async (teamId: string): Promise => { const statusPages = await StatusPageModel.find({ teamId }); return this.mapDocuments(statusPages); @@ -113,19 +132,37 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { image: Express.Multer.File | undefined, patch: Partial & { removeLogo?: string } ): Promise => { - const { logo, removeLogo, ...restPatch } = patch; + const { logo, removeLogo, customDomain, ...restPatch } = patch; void logo; - const updateData: StatusPageUpdateData = { ...restPatch }; + const setData: StatusPageUpdateData = { ...restPatch }; + const unsetData: Record = {}; + + if (customDomain !== undefined) { + const normalizedCustomDomain = normalizeStatusPageDomain(customDomain); + if (normalizedCustomDomain) { + setData.customDomain = normalizedCustomDomain; + } else { + unsetData.customDomain = 1; + } + } if (image) { - updateData.logo = { + setData.logo = { data: image.buffer as Buffer, contentType: image.mimetype, }; } else if (removeLogo === "true") { - updateData.logo = null; + setData.logo = null; } - const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, { + const updateQuery = + unsetData.customDomain !== undefined + ? { + $set: setData, + $unset: unsetData, + } + : setData; + + const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateQuery, { new: true, }); diff --git a/server/src/db/models/StatusPage.ts b/server/src/domain/status-pages/status-page.model.ts similarity index 87% rename from server/src/db/models/StatusPage.ts rename to server/src/domain/status-pages/status-page.model.ts index 0b6451492..3337fe998 100644 --- a/server/src/db/models/StatusPage.ts +++ b/server/src/domain/status-pages/status-page.model.ts @@ -1,12 +1,12 @@ import { Schema, model, type Types } from "mongoose"; -import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js"; +import type { StatusPage, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPageThemeModes, StatusPageThemes, StatusPageTypes, -} from "@/types/statusPage.js"; +} from "@/domain/status-pages/status-page.type.js"; type StatusPageDocumentBase = Omit< StatusPage, @@ -65,6 +65,11 @@ const StatusPageSchema = new Schema( required: true, default: "", }, + customDomain: { + type: String, + lowercase: true, + trim: true, + }, timezone: { type: String, }, @@ -128,6 +133,14 @@ const StatusPageSchema = new Schema( { timestamps: true } ); +StatusPageSchema.index( + { customDomain: 1 }, + { + unique: true, + partialFilterExpression: { customDomain: { $type: "string" } }, + } +); + const StatusPageModel = model("StatusPage", StatusPageSchema); export type { StatusPageDocument }; diff --git a/server/src/service/business/statusPageService.ts b/server/src/domain/status-pages/status-page.service.ts similarity index 62% rename from server/src/service/business/statusPageService.ts rename to server/src/domain/status-pages/status-page.service.ts index 179461f25..6be395e49 100644 --- a/server/src/service/business/statusPageService.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -1,10 +1,13 @@ -import { type IStatusPagesRepository } from "@/repositories/index.js"; -import { ISettingsService } from "@/service/system/settingsService.js"; -import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js"; +import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/domain/status-pages/status-page.type.js"; +import { AppError } from "@/utils/AppError.js"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; + getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -19,6 +22,30 @@ export class StatusPageService implements IStatusPageService { this.settingsService = settingsService; } + private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { + if (!customDomain) { + return; + } + + const clientHost = normalizeStatusPageDomain(this.settingsService.getSettings().clientHost); + if (clientHost && customDomain === clientHost) { + throw new AppError({ + message: "Custom domain cannot match the Checkmate instance host", + status: 400, + }); + } + }; + + private normalizeCustomDomainInput = (data: Partial): Partial => { + if (!("customDomain" in data)) { + return data; + } + + const customDomain = normalizeStatusPageDomain(data.customDomain); + this.assertCustomDomainAllowed(customDomain); + return { ...data, customDomain }; + }; + private withoutThemeFields = (data: Partial): Partial => { const { theme: _theme, themeMode: _themeMode, ...rest } = data; return rest; @@ -42,7 +69,8 @@ export class StatusPageService implements IStatusPageService { image: Express.Multer.File | undefined, data: Partial ): Promise => { - const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const created = await this.statusPagesRepository.create(userId, teamId, image, normalizedData); return this.normalizeTheme(created); }; @@ -51,13 +79,19 @@ export class StatusPageService implements IStatusPageService { return this.normalizeTheme(statusPage); }; + getStatusPageByCustomDomain = async (customDomain: string): Promise => { + const statusPage = await this.statusPagesRepository.findByCustomDomain(customDomain); + return this.normalizeTheme(statusPage); + }; + getStatusPagesByTeamId = async (teamId: string): Promise => { const statusPages = await this.statusPagesRepository.findByTeamId(teamId); return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData); return this.normalizeTheme(updated); }; diff --git a/server/src/types/statusPage.ts b/server/src/domain/status-pages/status-page.type.ts similarity index 95% rename from server/src/types/statusPage.ts rename to server/src/domain/status-pages/status-page.type.ts index 0ef4d72a9..254a411e1 100644 --- a/server/src/types/statusPage.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,7 +1,7 @@ export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; -export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const; +export const StatusPageThemes = ["refined", "modern", "bold", "editorial", "minimal"] as const; export type StatusPageTheme = (typeof StatusPageThemes)[number]; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; @@ -26,6 +26,7 @@ export interface StatusPage { type: StatusPageType[]; companyName: string; url: string; + customDomain?: string | null; timezone?: string; color: string; monitors: string[]; diff --git a/server/src/db/models/Tag.ts b/server/src/domain/tags/tag.model.ts similarity index 91% rename from server/src/db/models/Tag.ts rename to server/src/domain/tags/tag.model.ts index df77aa7ab..2fbfbf2c6 100644 --- a/server/src/db/models/Tag.ts +++ b/server/src/domain/tags/tag.model.ts @@ -1,5 +1,5 @@ import { Schema, model, type Types } from "mongoose"; -import type { Tag as TagEntity } from "@/types/index.js"; +import type { Tag as TagEntity } from "@/domain/tags/tag.type.js"; type TagDocumentBase = Omit; diff --git a/server/src/repositories/tags/ITagsRepository.ts b/server/src/domain/tags/tag.repository.interface.ts similarity index 88% rename from server/src/repositories/tags/ITagsRepository.ts rename to server/src/domain/tags/tag.repository.interface.ts index 42cec2a5a..ba6fe8eb3 100644 --- a/server/src/repositories/tags/ITagsRepository.ts +++ b/server/src/domain/tags/tag.repository.interface.ts @@ -1,4 +1,4 @@ -import { Tag } from "@/types/index.js"; +import { Tag } from "@/domain/tags/tag.type.js"; export interface ITagsRepository { // create diff --git a/server/src/repositories/tags/MongoTagsRepository.ts b/server/src/domain/tags/tag.repository.mongo.ts similarity index 92% rename from server/src/repositories/tags/MongoTagsRepository.ts rename to server/src/domain/tags/tag.repository.mongo.ts index 72355c7df..5d7d8d0a2 100644 --- a/server/src/repositories/tags/MongoTagsRepository.ts +++ b/server/src/domain/tags/tag.repository.mongo.ts @@ -1,6 +1,6 @@ -import { ITagsRepository } from "@/repositories/tags/ITagsRepository.js"; -import { Tag } from "@/types/tag.js"; -import { TagDocument, TagModel } from "@/db/models/index.js"; +import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; +import { Tag } from "@/domain/tags/tag.type.js"; +import { TagDocument, TagModel } from "@/domain/tags/tag.model.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/service/infrastructure/tagService.ts b/server/src/domain/tags/tag.service.ts similarity index 85% rename from server/src/service/infrastructure/tagService.ts rename to server/src/domain/tags/tag.service.ts index 5dc6f7cef..8c1806525 100644 --- a/server/src/service/infrastructure/tagService.ts +++ b/server/src/domain/tags/tag.service.ts @@ -1,5 +1,6 @@ -import { Tag } from "@/types/index.js"; -import { ITagsRepository, IMonitorsRepository } from "@/repositories/index.js"; +import { Tag } from "@/domain/tags/tag.type.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; export interface ITagsService { createTag(tag: Partial, teamId: string): Promise; getTag(tagId: string, teamId: string): Promise; diff --git a/server/src/types/tag.ts b/server/src/domain/tags/tag.type.ts similarity index 100% rename from server/src/types/tag.ts rename to server/src/domain/tags/tag.type.ts diff --git a/server/src/db/models/Team.ts b/server/src/domain/teams/team.model.ts similarity index 90% rename from server/src/db/models/Team.ts rename to server/src/domain/teams/team.model.ts index 608f90ad8..5fb98b5ce 100755 --- a/server/src/db/models/Team.ts +++ b/server/src/domain/teams/team.model.ts @@ -1,6 +1,6 @@ import { Schema, model, type Types } from "mongoose"; -import type { Team } from "@/types/index.js"; +import type { Team } from "@/domain/teams/team.type.js"; type TeamDocumentBase = Omit & {}; interface TeamDocument extends TeamDocumentBase { diff --git a/server/src/repositories/teams/ITeamsRepository.ts b/server/src/domain/teams/team.repository.interface.ts similarity index 74% rename from server/src/repositories/teams/ITeamsRepository.ts rename to server/src/domain/teams/team.repository.interface.ts index ba1b353cf..83854123c 100644 --- a/server/src/repositories/teams/ITeamsRepository.ts +++ b/server/src/domain/teams/team.repository.interface.ts @@ -1,4 +1,4 @@ -import type { Team } from "@/types/index.js"; +import type { Team } from "@/domain/teams/team.type.js"; export interface ITeamsRepository { // create create(email: string): Promise; diff --git a/server/src/repositories/teams/MongoTeamsRepository.ts b/server/src/domain/teams/team.repository.model.ts similarity index 84% rename from server/src/repositories/teams/MongoTeamsRepository.ts rename to server/src/domain/teams/team.repository.model.ts index 04e2be6f4..d050d6f59 100644 --- a/server/src/repositories/teams/MongoTeamsRepository.ts +++ b/server/src/domain/teams/team.repository.model.ts @@ -1,6 +1,6 @@ -import { Team } from "@/types/index.js"; -import { TeamDocument, TeamModel } from "@/db/models/index.js"; -import { ITeamsRepository } from "@/repositories/index.js"; +import { Team } from "@/domain/teams/team.type.js"; +import { TeamDocument, TeamModel } from "@/domain/teams/team.model.js"; +import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import mongoose from "mongoose"; class MongoTeamsRepository implements ITeamsRepository { diff --git a/server/src/types/team.ts b/server/src/domain/teams/team.type.ts similarity index 100% rename from server/src/types/team.ts rename to server/src/domain/teams/team.type.ts diff --git a/server/src/db/models/User.ts b/server/src/domain/users/user.model.ts similarity index 88% rename from server/src/db/models/User.ts rename to server/src/domain/users/user.model.ts index ac3895301..960702d89 100644 --- a/server/src/db/models/User.ts +++ b/server/src/domain/users/user.model.ts @@ -1,9 +1,9 @@ import { Schema, model, type Types } from "mongoose"; import bcrypt from "bcryptjs"; -import type { User, UserProfileImage, UserRole } from "@/types/index.js"; -import { MonitorModel } from "@/db/models/index.js"; -import Team from "./Team.js"; -import NotificationModel from "./Notification.js"; +import type { User, UserProfileImage, UserRole } from "@/domain/users/user.type.js"; +import { MonitorModel } from "@/domain/monitors/monitor.model.js"; +import Team from "../teams/team.model.js"; +import NotificationModel from "../notifications/notification.model.js"; type UserDocumentBase = Omit & { teamId?: Types.ObjectId; diff --git a/server/src/repositories/users/IUsersRepository.ts b/server/src/domain/users/user.repository.interface.ts similarity index 88% rename from server/src/repositories/users/IUsersRepository.ts rename to server/src/domain/users/user.repository.interface.ts index 3fa094c54..be6212993 100644 --- a/server/src/repositories/users/IUsersRepository.ts +++ b/server/src/domain/users/user.repository.interface.ts @@ -1,4 +1,4 @@ -import type { User } from "@/types/index.js"; +import type { User } from "@/domain/users/user.type.js"; export interface IUsersRepository { // create create(user: Partial, imageFile?: Express.Multer.File | null): Promise; diff --git a/server/src/repositories/users/MongoUsersRepository.ts b/server/src/domain/users/user.repository.mongo.ts similarity index 95% rename from server/src/repositories/users/MongoUsersRepository.ts rename to server/src/domain/users/user.repository.mongo.ts index a8525f2c8..1dc729b81 100644 --- a/server/src/repositories/users/MongoUsersRepository.ts +++ b/server/src/domain/users/user.repository.mongo.ts @@ -1,7 +1,7 @@ import mongoose from "mongoose"; -import { IUsersRepository } from "@/repositories/index.js"; -import { UserModel, type UserDocument } from "@/db/models/index.js"; -import type { User, UserProfileImage } from "@/types/index.js"; +import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import { UserModel, type UserDocument } from "@/domain/users/user.model.js"; +import type { User, UserProfileImage } from "@/domain/users/user.type.js"; import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; import { ParseBoolean } from "@/utils/utils.js"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/service/business/userService.ts b/server/src/domain/users/user.service.ts similarity index 92% rename from server/src/service/business/userService.ts rename to server/src/domain/users/user.service.ts index 8b2c52159..7bdb9677f 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/domain/users/user.service.ts @@ -1,19 +1,17 @@ -import { - IInvitesRepository, - IMonitorsRepository, - IRecoveryTokensRepository, - IUsersRepository, - ISettingsRepository, - ITeamsRepository, -} from "@/repositories/index.js"; -import type { User } from "@/types/index.js"; -import { canManageRole, type UserRole } from "@/types/user.js"; +import { ISettingsRepository } from "@/domain/app-settings/app-settings-repository.interface.js"; +import { IInvitesRepository } from "@/domain/invites/invite.repository.interface.js"; +import { IRecoveryTokensRepository } from "@/domain/recovery-tokens/recovery-token.repository.interface.js"; +import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; +import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { User } from "@/domain/users/user.type.js"; +import { canManageRole, type UserRole } from "@/domain/users/user.type.js"; import bcrypt from "bcryptjs"; import { AppError } from "@/utils/AppError.js"; -import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; -import { IEmailService } from "@/service/infrastructure/emailService.js"; -import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js"; +import { IEmailService } from "@/service/emailService.js"; +import { EnvConfig, ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; import jwt from "jsonwebtoken"; import crypto from "crypto"; type CryptoType = typeof crypto; @@ -55,7 +53,7 @@ export class UserService implements IUserService { private settingsService: ISettingsService; private logger: ILogger; private jwt: JwtType; - private jobQueue: IJobQueue; + private scheduler: IJobScheduler; private crypto: CryptoType; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; @@ -70,7 +68,7 @@ export class UserService implements IUserService { settingsService, logger, jwt, - jobQueue, + scheduler, monitorsRepository, usersRepository, invitesRepository, @@ -83,7 +81,7 @@ export class UserService implements IUserService { settingsService: ISettingsService; logger: ILogger; jwt: JwtType; - jobQueue: IJobQueue; + scheduler: IJobScheduler; monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; @@ -95,7 +93,7 @@ export class UserService implements IUserService { this.settingsService = settingsService; this.logger = logger; this.jwt = jwt; - this.jobQueue = jobQueue; + this.scheduler = scheduler; this.crypto = crypto; this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; @@ -332,7 +330,7 @@ export class UserService implements IUserService { if (roles.includes("superadmin")) { const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); if (monitors) { - await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor))); + await Promise.all(monitors.map((monitor) => this.scheduler.deleteJob(monitor))); } } // 6. Delete the user by id diff --git a/server/src/types/user.ts b/server/src/domain/users/user.type.ts similarity index 100% rename from server/src/types/user.ts rename to server/src/domain/users/user.type.ts diff --git a/server/src/index.ts b/server/src/index.ts index 45c75ac8b..44536ce8b 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,58 +1,104 @@ -import { initializeServices } from "./config/services.js"; +import { buildShared } from "@/config/services.shared.js"; +import { buildApi } from "@/config/services.api.js"; +import { buildWorker } from "@/config/services.worker.js"; + import { initializeControllers } from "./config/controllers.js"; import { createApp } from "./app.js"; import { initShutdownListener } from "@/shutdown.js"; -import { validateEnv } from "@/validation/envValidation.js"; +import { validateEnv } from "@/config/envValidation.js"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; import Logger, { ILogger } from "@/utils/logger.js"; -import { SettingsService } from "@/service/index.js"; - +import { SettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { JobScheduler } from "@/worker/worker.job-scheduler.js"; +import { IJobScheduler } from "@/worker/worker.interface.js"; +import { HealthServer } from "@/worker/worker.health-server.js"; const SERVICE_NAME = "Server"; let logger: ILogger; const startApp = async () => { - // Validate environment variables first - const env = validateEnv(); + // *********************** + // Basic setup + // *********************** - // Create settings service (env only — DB repository injected after connect) + logger = new Logger(); + const env = validateEnv(logger); + logger.setLogLevel(env.LOG_LEVEL); const settingsService = new SettingsService(env); const envSettings = settingsService.loadSettings(); - // Create logger - logger = new Logger({ envSettings }); + const { queueMode, queuePrimaryProcesses } = envSettings; - // Initialize services (connects DB, creates repositories, injects settingsRepository) - const services = await initializeServices({ logger, envSettings, settingsService, queueMode: envSettings.queueMode }); + logger.info({ + message: `Queue mode: ${queueMode}`, + service: SERVICE_NAME, + method: "startApp", + }); - // If this is a worker instance, we're done. No need for express - if (envSettings.queueMode === "worker") { - logger.info({ - message: "Worker instance started. API will not be started", - service: SERVICE_NAME, - }); - initShutdownListener(null, services); + logger.info({ + message: `Process jobs: ${queueMode === "worker" ? true : queuePrimaryProcesses}`, + service: SERVICE_NAME, + method: "startApp", + }); + + // *********************** + // Build shared services + // *********************** + const shared = await buildShared({ logger, envSettings, settingsService }); + + // *********************** + // Worker node path, don't need API + // *********************** + if (queueMode === "worker") { + const { worker } = await buildWorker(shared, envSettings); + const healthServer = new HealthServer(logger, env.HEALTH_PORT, worker); + await healthServer.listen(); + logger.info({ message: "Worker instance started. API will not be started", service: SERVICE_NAME }); + initShutdownListener(null, { worker, db: shared.db, logger, healthServer }); return; } - // FE path + // *********************** + // Primary node path + // *********************** + let scheduler: IJobScheduler; + + // *********************** + //Primary node processes jobs, need full worker + // *********************** + if (queuePrimaryProcesses === true) { + const { worker } = await buildWorker(shared, envSettings); + scheduler = worker; + } + // *********************** + // Primary node does not process jobs, only need scheduler + // *********************** + else { + scheduler = new JobScheduler(shared.jobsRepository, shared.queueWorkersRepository, shared.monitorsRepository, queueMode, logger, shared.workerId); + await scheduler.init(); // register in the queue_workers registry + reconcile (seed the jobs collection) + } + + const services = buildApi(shared, scheduler); + + // *********************** + // FE assets always needed for primary node + // *********************** + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); const frontendPath = path.join(__dirname, "..", "public"); - // Initialize controllers const controllers = initializeControllers(services); const app = createApp({ - services, + apiServices: services, controllers, envSettings, frontendPath, openApiSpec, }); - const server = app.listen(env.PORT, () => { logger.info({ message: `Server started on port:${env.PORT}` }); }); diff --git a/server/src/middleware/handleErrors.ts b/server/src/middleware/handleErrors.ts deleted file mode 100755 index ff591484f..000000000 --- a/server/src/middleware/handleErrors.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { NextFunction, Request, Response } from "express"; -import { logger } from "@/utils/logger.js"; -import { AppError } from "@/utils/AppError.js"; - -const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => { - const status = error instanceof AppError ? error.status || 500 : 500; - const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; - const service = error instanceof AppError ? error.service : "unknownService"; - const method = error instanceof AppError ? error.method : "unknownMethod"; - logger.error({ - message: message, - service: service, - method: method, - stack: error instanceof Error ? error.stack : undefined, - details: error instanceof AppError ? error.details : undefined, - }); - res.status(status).json({ - status, - msg: message, - }); -}; - -export { handleErrors }; diff --git a/server/src/middleware/rateLimiter.ts b/server/src/middleware/rateLimiter.ts deleted file mode 100644 index 99655149d..000000000 --- a/server/src/middleware/rateLimiter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import rateLimit from "express-rate-limit"; - -export const generalApiLimiter = rateLimit({ - windowMs: 60 * 1000, - limit: 600, - standardHeaders: true, - legacyHeaders: false, - ipv6Subnet: 64, -}); - -export const authApiLimiter = rateLimit({ - windowMs: 60 * 1000, - limit: 15, - standardHeaders: true, - legacyHeaders: false, - ipv6Subnet: 64, -}); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts deleted file mode 100644 index d36d9d189..000000000 --- a/server/src/repositories/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -export * from "@/repositories/monitors/IMonitorsRepository.js"; -export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js"; - -export * from "@/repositories/checks/IChecksRepository.js"; -export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js"; - -export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js"; -export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js"; - -export * from "@/repositories/status-pages/IStatusPagesRepository.js"; -export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; - -export * from "@/repositories/users/IUsersRepository.js"; -export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; - -export * from "@/repositories/invites/IInvitesRepository.js"; -export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; - -export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; -export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; - -export * from "@/repositories/settings/ISettingsRepository.js"; -export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; - -export * from "@/repositories/notifications/INotificationsRepository.js"; -export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; - -export * from "@/repositories/tags/ITagsRepository.js"; -export { default as MongoTagsRepository } from "@/repositories/tags/MongoTagsRepository.js"; - -export * from "@/repositories/incidents/IIncidentsRepository.js"; -export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; - -export * from "@/repositories/teams/ITeamsRepository.js"; -export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js"; - -export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js"; -export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js"; - -export * from "@/repositories/geo-checks/IGeoChecksRepository.js"; -export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js"; - -export * from "@/repositories/dlq/IDLQRepository.js"; -export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; - -export * from "@/repositories/queue-workers/IQueueWorkersRepository.js"; -export { default as MongoQueueWorkersRepository } from "@/repositories/queue-workers/MongoQueueWorkersRepository.js"; diff --git a/server/src/repositories/queue-workers/IQueueWorkersRepository.ts b/server/src/repositories/queue-workers/IQueueWorkersRepository.ts deleted file mode 100644 index b1f7e76b1..000000000 --- a/server/src/repositories/queue-workers/IQueueWorkersRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { QueueMode, QueueWorker } from "@/types/settings.js"; - -export interface IQueueWorkersRepository { - upsert(workerId: string, mode: QueueMode): Promise; - findRecent(maxAgeMs: number): Promise; - deleteById(workerId: string): Promise; -} diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/bufferService.ts similarity index 68% rename from server/src/service/infrastructure/bufferService.ts rename to server/src/service/bufferService.ts index 5a1fb3d12..d47a6472e 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/bufferService.ts @@ -1,9 +1,10 @@ -import type { Check } from "@/types/index.js"; -import type { GeoCheck } from "@/types/index.js"; -import type { IGeoChecksService } from "../business/geoChecksService.js"; +import type { Check } from "@/domain/checks/check.type.js"; +import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js"; +import type { IGeoChecksService } from "../domain/geo-checks/geo-check.service.js"; import type { ILogger } from "@/utils/logger.js"; -import type { ISettingsService } from "@/service/system/settingsService.js"; -import { ICheckService } from "../business/checkService.js"; +import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import { ICheckService } from "../domain/checks/check.service.js"; const SERVICE_NAME = "BufferService"; export interface IBufferService { @@ -12,6 +13,7 @@ export interface IBufferService { scheduleNextFlush(): void; flushBuffer(): Promise; flushGeoBuffer(): Promise; + shutdown(): Promise; } export class BufferService implements IBufferService { @@ -24,12 +26,20 @@ export class BufferService implements IBufferService { private bufferTimer: NodeJS.Timeout | null = null; private checksService: ICheckService; private geoChecksService: IGeoChecksService; + private jobsRepository: IJobsRepository; - constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) { + constructor( + logger: ILogger, + checkService: ICheckService, + geoChecksService: IGeoChecksService, + settingsService: ISettingsService, + jobsRepository: IJobsRepository + ) { this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute this.logger = logger; this.checksService = checkService; this.geoChecksService = geoChecksService; + this.jobsRepository = jobsRepository; this.SERVICE_NAME = SERVICE_NAME; this.buffer = []; this.geoBuffer = []; @@ -93,16 +103,23 @@ export class BufferService implements IBufferService { }, this.BUFFER_TIMEOUT); } async flushBuffer() { + if (this.buffer.length === 0) { + return; + } + // Take the batch first so a write that drains it can't be appended to mid-flush + const batch = this.buffer; + this.buffer = []; try { - if (this.buffer.length > 0) { - this.logger.debug({ - message: `Flushing ${this.buffer.length} checks to database`, - service: this.SERVICE_NAME, - method: "flushBuffer", - }); - await this.checksService.createChecks(this.buffer); - this.buffer = []; - } + this.logger.debug({ + message: `Flushing ${batch.length} checks to database`, + service: this.SERVICE_NAME, + method: "flushBuffer", + }); + await this.checksService.createChecks(batch); + // Need to evaluate checks when they are flushed + const monitorIds = [...new Set(batch.map((check) => check.metadata.monitorId))]; + const now = Date.now(); + await Promise.all(monitorIds.map((monitorId) => this.jobsRepository.upsertEvaluate(monitorId, now))); } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", @@ -110,8 +127,7 @@ export class BufferService implements IBufferService { method: "flushBuffer", stack: error instanceof Error ? error.stack : undefined, }); - // Clear buffer even on error to prevent infinite retry loops - this.buffer = []; + // Batch is already cleared from the buffer; dropping it prevents infinite retry loops } } @@ -137,4 +153,13 @@ export class BufferService implements IBufferService { this.geoBuffer = []; } } + + async shutdown() { + if (this.bufferTimer) { + clearTimeout(this.bufferTimer); + this.bufferTimer = null; + } + await this.flushBuffer(); + await this.flushGeoBuffer(); + } } diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/emailService.ts similarity index 95% rename from server/src/service/infrastructure/emailService.ts rename to server/src/service/emailService.ts index 4428b0477..c156cc18b 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/emailService.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "url"; -import { EmailTransportConfig } from "@/types/index.js"; -import { ISettingsService } from "@/service/system/settingsService.js"; +import { EmailTransportConfig } from "@/domain/app-settings/app-settings.type.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; import fs from "node:fs"; import path from "node:path"; @@ -63,7 +63,7 @@ export class EmailService implements IEmailService { init = () => { const loadTemplate = (templateName: string) => { try { - const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); + const templatePath = this.path.join(__dirname, `../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error: unknown) { diff --git a/server/src/service/infrastructure/globalPingService.ts b/server/src/service/globalPingService.ts similarity index 88% rename from server/src/service/infrastructure/globalPingService.ts rename to server/src/service/globalPingService.ts index 41f96b286..cebd95408 100644 --- a/server/src/service/infrastructure/globalPingService.ts +++ b/server/src/service/globalPingService.ts @@ -1,8 +1,9 @@ -import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/types/geoCheck.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; -import { MonitorType } from "@/types/index.js"; +import type { GeoContinent, GeoCheckResult, GeoCheckTimings, GeoCheckLocation } from "@/domain/geo-checks/geo-check.type.js"; +import { supportsGeoCheck, type HttpStatusCode } from "@/domain/monitors/monitor.types.js"; +import { MonitorType } from "@/domain/monitors/monitor.types.js"; import type { ILogger } from "@/utils/logger.js"; import got from "got"; +import { isStatusUp } from "@/service/network/utils.js"; const SERVICE_NAME = "GlobalPingService"; const GLOBAL_PING_API_BASE = "https://api.globalping.io/v1"; @@ -62,7 +63,7 @@ interface GlobalPingProbeResult { export interface IGlobalPingService { readonly serviceName: string; createMeasurement(monitorType: MonitorType, url: string, locations: GeoContinent[]): Promise; - pollForResults(measurementId: string, timeoutMs?: number): Promise; + pollForResults(measurementId: string, timeoutMs?: number, customUpCodes?: HttpStatusCode[]): Promise; } export class GlobalPingService implements IGlobalPingService { @@ -119,7 +120,11 @@ export class GlobalPingService implements IGlobalPingService { } } - async pollForResults(measurementId: string, timeoutMs: number = MAX_POLL_TIMEOUT_MS): Promise { + async pollForResults( + measurementId: string, + timeoutMs: number = MAX_POLL_TIMEOUT_MS, + customUpCodes: HttpStatusCode[] = [] + ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -132,7 +137,7 @@ export class GlobalPingService implements IGlobalPingService { const measurement = response.body; if (measurement.status === "finished") { - const results = this.transformResults(measurement.results || []); + const results = this.transformResults(measurement.results || [], customUpCodes); this.logger.debug({ message: `GlobalPing measurement completed: ${measurementId}`, service: SERVICE_NAME, @@ -174,7 +179,7 @@ export class GlobalPingService implements IGlobalPingService { return []; } - private transformResults(probeResults: GlobalPingProbeResult[]): GeoCheckResult[] { + private transformResults(probeResults: GlobalPingProbeResult[], customUpCodes: HttpStatusCode[] = []): GeoCheckResult[] { const successfulResults: GeoCheckResult[] = []; for (const probeResult of probeResults) { @@ -205,7 +210,7 @@ export class GlobalPingService implements IGlobalPingService { successfulResults.push({ location, - status: probeResult.result.statusCode >= 200 && probeResult.result.statusCode < 300, + status: isStatusUp(probeResult.result.statusCode, customUpCodes), statusCode: probeResult.result.statusCode, timings, }); diff --git a/server/src/service/index.ts b/server/src/service/index.ts deleted file mode 100644 index 2a0974e27..000000000 --- a/server/src/service/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Business services -export * from "@/service/business/checkService.js"; -export * from "@/service/business/diagnosticService.js"; -export * from "@/service/business/geoChecksService.js"; -export * from "@/service/business/incidentService.js"; -export * from "@/service/business/inviteService.js"; -export * from "@/service/business/maintenanceWindowService.js"; -export * from "@/service/business/monitorService.js"; -export * from "@/service/business/statusPageService.js"; -export * from "@/service/business/userService.js"; - -// Infrastructure services -export * from "@/service/infrastructure/JobQueues/IJobQueue.js"; -export * from "@/service/infrastructure/JobQueues/SuperSimpleQueue.js"; -export * from "@/service/infrastructure/JobQueues/LessSimpleQueue.js"; -export * from "@/service/infrastructure/JobQueues/QueueHelper.js"; -export * from "@/service/infrastructure/notificationMessageBuilder.js"; -export * from "@/service/infrastructure/bufferService.js"; -export * from "@/service/infrastructure/emailService.js"; -export * from "@/service/infrastructure/globalPingService.js"; -export * from "@/service/infrastructure/networkService.js"; -export * from "@/service/infrastructure/notificationsService.js"; -export * from "@/service/infrastructure/tagService.js"; -export * from "@/service/infrastructure/statusService.js"; - -// Notification providers -export * from "@/service/infrastructure/notificationProviders/discord.js"; -export * from "@/service/infrastructure/notificationProviders/email.js"; -export * from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; -export * from "@/service/infrastructure/notificationProviders/matrix.js"; -export * from "@/service/infrastructure/notificationProviders/pagerduty.js"; -export * from "@/service/infrastructure/notificationProviders/slack.js"; -export * from "@/service/infrastructure/notificationProviders/teams.js"; -export * from "@/service/infrastructure/notificationProviders/webhook.js"; -export * from "@/service/infrastructure/notificationProviders/telegram.js"; -export * from "@/service/infrastructure/notificationProviders/pushover.js"; -export * from "@/service/infrastructure/notificationProviders/twilio.js"; -export * from "@/service/infrastructure/notificationProviders/ntfy.js"; - -// System services -export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts deleted file mode 100644 index ccbca625a..000000000 --- a/server/src/service/infrastructure/JobQueues/IJobQueue.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Monitor } from "@/types/monitor.js"; -import { QueueWorker } from "@/types/settings.js"; - -export type QueueJobFailure = { - monitorId: string | number; - monitorUrl: string | null; - monitorType: string | null; - failedAt: number | null; - failCount: number; - failReason: string | null; -}; - -export type QueueMetrics = { - jobs: number; - activeJobs: number; - failingJobs: number; - jobsWithFailures: QueueJobFailure[]; - totalRuns: number; - totalFailures: number; - workers: QueueWorker[]; -}; - -export type QueueJobSummary = { - monitorId: string | number; - monitorUrl: string | null; - monitorType: string | null; - monitorInterval: number | null; - monitorGeoInterval: number | null; - monitorActive: boolean | null; - active: boolean; - lockedBy: string | null; - lockedUntil: number | null; - lockedAt: number | null; - runCount: number; - failCount: number; - failReason: string | null; - lastRunAt: number | null; - lastFinishedAt: number | null; - lastRunTook: number | null; - lastFailedAt: number | null; - repeat: number | null; -}; - -export type QueueJobsPagination = { - page?: number; - rowsPerPage?: number; -}; - -export type QueueJobsPage = { - jobs: QueueJobSummary[]; - count: number; -}; - -export interface IJobQueue { - readonly serviceName: string; - init(): Promise; - addJob(monitorId: string, monitor: Monitor): Promise; - deleteJob(monitor: Monitor): Promise; - pauseJob(monitor: Monitor): Promise; - resumeJob(monitor: Monitor): Promise; - updateJob(monitor: Monitor): Promise; - shutdown(): Promise; - getMetrics(): Promise; - getJobs(pagination: QueueJobsPagination): Promise; - flushQueues(): Promise<{ success: boolean }>; -} diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts deleted file mode 100644 index 963b41267..000000000 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { IMonitorsRepository, IQueueWorkersRepository } from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler"; -import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; -import { Monitor, supportsGeoCheck } from "@/types/monitor.js"; -import { type QueueMetrics, type QueueJobsPagination, type QueueJobsPage, IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; -import { type QueueMode } from "@/types/settings.js"; -import { EnvConfig } from "@/service/system/settingsService.js"; -const SERVICE_NAME = "JobQueue"; - -// How long after its last heartbeat a worker is still considered alive. Must be longer than the scheduler's heartbeat -const WORKER_TTL_MS = 30_000; - -// For discriminating job types -type MonitorJob = Omit & { data: Monitor }; -const MONITOR_TEMPLATES = new Set(["monitor-job", "geo-check-job"]); -const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template); - -export class LessSimpleQueue implements IJobQueue { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private helper: IQueueHelper; - private monitorsRepository: IMonitorsRepository; - private workersRepository: IQueueWorkersRepository; - private readonly scheduler: Scheduler; - private queueMode: QueueMode; - private heartbeatListener: ((workerId: string) => void) | null = null; - - constructor( - logger: ILogger, - helper: IQueueHelper, - monitorsRepository: IMonitorsRepository, - workersRepository: IQueueWorkersRepository, - scheduler: Scheduler, - queueMode: QueueMode - ) { - this.logger = logger; - this.helper = helper; - this.monitorsRepository = monitorsRepository; - this.workersRepository = workersRepository; - this.scheduler = scheduler; - this.queueMode = queueMode; - this.registerListeners(); - } - - get serviceName() { - return LessSimpleQueue.SERVICE_NAME; - } - - private registerListeners = () => { - this.scheduler.on("scheduler:start", () => { - this.logger.info({ - message: "Scheduler started", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:stop", () => { - this.logger.info({ - message: "Scheduler stopped", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:error", (workerId, error) => { - this.logger.error({ - message: `${workerId} error: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:locked", (workerId, job) => { - this.logger.debug({ - message: `${workerId} ${job.id} locked`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:abort", (workerId, job, reason) => { - this.logger.warn({ - message: `${workerId} ${job.id} aborted: ${reason}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:attempt", (workerId, job, attempt) => { - this.logger.debug({ - message: `${workerId} ${job.id} attempt ${attempt}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:complete", (workerId, job) => { - this.logger.debug({ - message: `${workerId} ${job.id} completed successfully`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:exhausted", (workerId, job, error) => { - this.logger.error({ - message: `${workerId} ${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:fail", (workerId, job, error, attempt) => { - this.logger.warn({ - message: `${workerId} ${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:start", (workerId, job) => { - this.logger.debug({ - message: `${workerId} ${job.id} started`, - service: SERVICE_NAME, - }); - }); - }; - - static async create( - logger: ILogger, - helper: IQueueHelper, - monitorsRepository: IMonitorsRepository, - workersRepository: IQueueWorkersRepository, - envSettings: EnvConfig, - queueMode: QueueMode - ) { - const store = new MongoStore({ - url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db", - }); - const scheduler = new Scheduler(store, { - concurrency: envSettings.queueMode === "primary" ? 0 : 50, - processEvery: 100, - }); - const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode); - await instance.init(queueMode); - - return instance; - } - - private startHeartbeat = (queueMode: QueueMode) => { - const beat = ( - workerId: string // This is the heartbeat function that registers workers - ) => - this.workersRepository.upsert(workerId, queueMode).catch((error: unknown) => { - this.logger.warn({ - message: `Worker heartbeat failed: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - method: "startHeartbeat", - }); - }); - beat(this.scheduler.workerId); // Beat immediately to register - if (this.heartbeatListener) { - this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); // remove old listeners - } - this.heartbeatListener = beat; /// Keep a reference to the listener - this.scheduler.on("scheduler:heartbeat", beat); // beat on every heartbeat - }; - - init = async (queueMode: QueueMode = "primary") => { - try { - await this.scheduler.start(); - this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); - this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); - this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); - this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); - - // Register in the worker registry - this.startHeartbeat(queueMode); - - // If this is a worker, return early - primary will populate queue - if (queueMode === "worker") { - this.logger.info({ - message: `Worker queue: ${this.scheduler.workerId} initialized`, - service: SERVICE_NAME, - method: "init", - }); - return true; - } - - const monitors = await this.monitorsRepository.findAll(); - if (!monitors) { - return true; - } - const jobInputs = monitors.flatMap((monitor) => this.buildJobInputs(monitor)); - if (jobInputs.length > 0) { - await this.scheduler.addJobs(jobInputs); - } - - this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, upsert: true }); - this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000, upsert: true }); - - return true; - } catch (error: unknown) { - this.logger.error({ - message: `Failed to initialize LessSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - method: "init", - }); - return false; - } - }; - - // Builds the scheduler inputs for a monitor: always a monitor-job, plus a - // geo-check job when the monitor supports and enables geo checks. - private buildJobInputs = (monitor: Monitor): AddJobInput[] => { - const inputs: AddJobInput[] = [ - { - id: monitor.id, - template: "monitor-job", - repeat: monitor.interval, - active: monitor.isActive, - data: monitor, - upsert: true, - jitter: true, - }, - ]; - - if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { - inputs.push({ - id: `${monitor.id}-geo`, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - upsert: true, - jitter: true, - }); - } - - return inputs; - }; - - addJob = async (_monitorId: string, monitor: Monitor) => { - await this.scheduler.addJobs(this.buildJobInputs(monitor)); - }; - - deleteJob = async (monitor: Monitor) => { - this.scheduler.removeJob(monitor.id); - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); - }; - - pauseJob = async (monitor: Monitor) => { - const result = await this.scheduler.pauseJob(monitor.id); - if (result === false) { - throw new Error("Failed to pause monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to pause geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - - this.logger.debug({ - message: `Paused monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - }; - - resumeJob = async (monitor: Monitor) => { - const result = await this.scheduler.resumeJob(monitor.id); - if (result === false) { - throw new Error("Failed to resume monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to resume geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - this.logger.debug({ - message: `Resumed monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - }; - - private syncGeoJob = async (monitor: Monitor) => { - const geoJobId = `${monitor.id}-geo`; - const existingGeoJob = await this.scheduler.getJob(geoJobId); - - // If geoChecks have been disabled, or the monitor type doesn't support them, remove - if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { - if (existingGeoJob) this.scheduler.removeJob(geoJobId); - return; - } - - // If the job exists, update it - if (existingGeoJob) { - this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); - return; - } - - // Otherwise, create it - this.scheduler.addJob({ - id: geoJobId, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - upsert: true, - }); - }; - - updateJob = async (monitor: Monitor) => { - this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); - await this.syncGeoJob(monitor); - }; - - shutdown = async () => { - // Stop beating before we remove this worker, so a heartbeat firing between - // the remove and the scheduler stopping can't re-insert it. - if (this.heartbeatListener) { - this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); - this.heartbeatListener = null; - } - await this.workersRepository.deleteById(this.scheduler.workerId).catch((error: unknown) => { - this.logger.warn({ - message: `Failed to deregister worker on shutdown: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - method: "shutdown", - }); - }); - this.scheduler.stop(); - }; - - getMetrics = async (): Promise => { - const stats = await this.scheduler.getStats(); - - const jobsWithFailures = stats.jobsWithFailures.map((failure) => { - // `data` is untyped at the store level: for monitor jobs it's the Monitor, - // for system jobs (e.g. cleanup) it has no url/type => null - const monitor = failure.data as Monitor | undefined; - return { - monitorId: failure.id, - monitorUrl: monitor?.url ?? null, - monitorType: monitor?.type ?? null, - failedAt: failure.failedAt, - failCount: failure.failCount, - failReason: failure.failReason, - }; - }); - - const workers = await this.workersRepository.findRecent(WORKER_TTL_MS); - return { - jobs: stats.jobs, - activeJobs: stats.activeJobs, - failingJobs: stats.failingJobs, - totalRuns: stats.totalRuns, - totalFailures: stats.totalFailures, - jobsWithFailures, - workers, - }; - }; - - getJobs = async ({ page = 0, rowsPerPage = 0 }: QueueJobsPagination): Promise => { - const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined; - const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]); - const mapped = jobs.map((job) => { - const monitor = isMonitorJob(job) ? job.data : undefined; - - return { - monitorId: job.id, - monitorUrl: monitor?.url || null, - monitorType: monitor?.type || null, - monitorInterval: monitor?.interval || null, - monitorGeoInterval: monitor?.geoCheckInterval || null, - monitorActive: monitor?.isActive ?? null, - active: job.active, - repeat: job.repeat ?? null, - lockedBy: job.lockedBy ?? null, - lockedUntil: job.lockedUntil ?? null, - lockedAt: job.lockedAt ?? null, - runCount: job.runCount ?? 0, - failCount: job.failCount ?? 0, - failReason: job.lastError ?? null, - lastRunAt: job.lastStartedAt ?? null, - lastFinishedAt: job.lastFinishedAt ?? null, - lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt, - lastFailedAt: job.lastFailedAt ?? null, - }; - }); - - return { jobs: mapped, count }; - }; - - flushQueues = async () => { - if (this.queueMode !== "primary") { - // Don't allow workers to flush, though this isn't currently reachable since workers have no API endpoints - this.logger.warn({ message: "Ignoring flush on non-primary worker", service: SERVICE_NAME, method: "flushQueues" }); - return { success: false }; - } - - const flushRes = await this.scheduler.flushJobs(); // Less Simple Queue must flush before stopping as it needs DB - const stopRes = await this.scheduler.stop(); // Destructive, other workers will abort their jobs - const initRes = await this.init(); - return { - success: Boolean(stopRes && flushRes && initRes), - }; - }; -} diff --git a/server/src/service/infrastructure/JobQueues/QueueHelper.ts b/server/src/service/infrastructure/JobQueues/QueueHelper.ts deleted file mode 100644 index ea86feae0..000000000 --- a/server/src/service/infrastructure/JobQueues/QueueHelper.ts +++ /dev/null @@ -1,437 +0,0 @@ -const SERVICE_NAME = "JobQueueHelper"; -import type { Monitor } from "@/types/monitor.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; -import { AppError } from "@/utils/AppError.js"; -import { - ICheckService, - INetworkService, - INotificationsService, - ISettingsService, - IStatusService, - IncidentService, - type IGeoChecksService, -} from "@/service/index.js"; -import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js"; -import { isWindowActive } from "@/utils/maintenanceWindow.js"; -import { - IMaintenanceWindowsRepository, - IMonitorsRepository, - ITeamsRepository, - IMonitorStatsRepository, - IChecksRepository, - IIncidentsRepository, - IGeoChecksRepository, -} from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import { IBufferService } from "@/service/index.js"; - -export interface IQueueHelper { - readonly serviceName: string; - getHeartbeatJob(): (monitor: Monitor) => Promise; - getHeartbeatGeoJob(): (monitor: Monitor) => Promise; - getCleanupOrphanedJob(): () => Promise; - getCleanupRetentionJob(): () => Promise; - isInMaintenanceWindow(monitorId: string, teamId: string): Promise; -} - -export interface MonitorActionDecision { - shouldCreateIncident: boolean; - shouldResolveIncident: boolean; - shouldSendNotification: boolean; - incidentReason: "status_down" | "threshold_breach" | null; - notificationReason: "status_change" | "threshold_breach" | null; - thresholdBreaches?: { - cpu?: boolean; - memory?: boolean; - disk?: boolean; - temp?: boolean; - }; -} - -export class QueueHelper implements IQueueHelper { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private networkService: INetworkService; - private statusService: IStatusService; - private notificationsService: INotificationsService; - private checkService: ICheckService; - private settingsService: ISettingsService; - private buffer: IBufferService; - private incidentService: IncidentService; - private maintenanceWindowsRepository: IMaintenanceWindowsRepository; - private monitorsRepository: IMonitorsRepository; - private teamsRepository: ITeamsRepository; - private monitorStatsRepository: IMonitorStatsRepository; - private checksRepository: IChecksRepository; - private incidentsRepository: IIncidentsRepository; - private geoChecksService: IGeoChecksService; - private geoChecksRepository: IGeoChecksRepository; - - constructor( - logger: ILogger, - networkService: INetworkService, - statusService: IStatusService, - notificationsService: INotificationsService, - checkService: ICheckService, - settingsService: ISettingsService, - buffer: IBufferService, - incidentService: IncidentService, - maintenanceWindowsRepository: IMaintenanceWindowsRepository, - monitorsRepository: IMonitorsRepository, - teamsRepository: ITeamsRepository, - monitorStatsRepository: IMonitorStatsRepository, - checksRepository: IChecksRepository, - incidentsRepository: IIncidentsRepository, - geoChecksService: IGeoChecksService, - geoChecksRepository: IGeoChecksRepository - ) { - this.logger = logger; - this.networkService = networkService; - this.statusService = statusService; - this.checkService = checkService; - this.settingsService = settingsService; - this.buffer = buffer; - this.notificationsService = notificationsService; - this.incidentService = incidentService; - this.maintenanceWindowsRepository = maintenanceWindowsRepository; - this.monitorsRepository = monitorsRepository; - this.teamsRepository = teamsRepository; - this.monitorStatsRepository = monitorStatsRepository; - this.checksRepository = checksRepository; - this.incidentsRepository = incidentsRepository; - this.geoChecksService = geoChecksService; - this.geoChecksRepository = geoChecksRepository; - } - - get serviceName() { - return QueueHelper.SERVICE_NAME; - } - - getHeartbeatJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); - } - - // Step 1. Check for maintenance window, if found, skip the check - - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window`, - service: SERVICE_NAME, - method: "getMonitorJob", - }); - if (monitor.status !== "maintenance") { - await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" }); - } - return; - } - - // Step 2. Request monitor status - const status = await this.networkService.requestStatus(monitor); - if (!status) { - throw new Error("No network response"); - } - - // Step 3. Build check - const check = this.checkService.buildCheck(status); - if (!check) { - this.logger.warn({ - message: `No check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getMonitorJob", - details: { code: status.code, message: status.message }, - }); - return; - } - // Step 4 Add check to buffer - this.buffer.addToBuffer(check); - // Step 4. Update monitor status - const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); - - // Step 5. Get decisions - const decision = this.evaluateMonitorAction(statusChangeResult); - - // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) - if (decision.shouldSendNotification) { - this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => { - this.logger.error({ - message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - }); - } - - // Step 7. Handle incidents - try { - await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status); - } catch (error: unknown) { - this.logger.warn({ - message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getCleanupOrphanedJob = () => { - return async () => { - try { - this.logger.info({ - message: "Starting cleanup of orphaned data", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Get all valid team IDs - const validTeamIds = await this.teamsRepository.findAllTeamIds(); - this.logger.debug({ - message: `Found ${validTeamIds.length} valid teams`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Remove orphaned monitors (monitors without a valid team) - const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); - if (deletedMonitorCount > 0) { - this.logger.info({ - message: `Deleted ${deletedMonitorCount} orphaned monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned monitorStats (stats without a valid monitor) - const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); - this.logger.debug({ - message: `Found ${allMonitorIds.length} valid monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedStatsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedStatsCount} orphaned monitor stats`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned checks - const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedChecksCount} orphaned checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned incidents - const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedIncidentsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedIncidentsCount} orphaned incidents`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned geo checks - const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedGeoChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - this.logger.info({ - message: "Cleanup of orphaned data completed", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getHeartbeatGeoJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - - // Step 1: Validate monitor eligibility - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); - } - - if (!monitor.geoCheckEnabled) { - return; - } - if (!supportsGeoCheck(monitor.type)) { - this.logger.debug({ - message: `Monitor ${monitorId} type does not support geo checks, skipping`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { - this.logger.warn({ - message: `No geo check locations configured for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 2: Check for maintenance window - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 3: Build geo check (handles API calls and polling) - const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); - if (!geoCheck) { - this.logger.warn({ - message: `No geo check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 4: Add geo check to buffer - this.buffer.addGeoCheckToBuffer(geoCheck); - - this.logger.debug({ - message: `Geo check job executed for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - stack: error instanceof Error ? error.stack : undefined, - }); - // Don't throw - geo check failures shouldn't crash the job scheduler - } - }; - }; - - async isInMaintenanceWindow(monitorId: string, teamId: string) { - const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); - const now = new Date(); - return maintenanceWindows.some((window) => isWindowActive(window, now)); - } - - getCleanupRetentionJob = () => { - return async () => { - try { - const settings = await this.settingsService.getDBSettings(); - - const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS - - if (checkTTL === CHECK_TTL_SENTINEL) { - this.logger.info({ - message: `Check TTL is set to unlimited, skipping cleanup`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - return; - } - const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; - const cutoffDate = new Date(Date.now() - checkTTLInMs); - const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); - this.logger.info({ - message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; - }; - - private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { - const { monitor, statusChanged, prevStatus } = statusChangeResult; - - // Initialize result - const decision: MonitorActionDecision = { - shouldCreateIncident: false, - shouldResolveIncident: false, - shouldSendNotification: false, - incidentReason: null, - notificationReason: null, - }; - - if (!statusChanged) { - return decision; - } - - if (monitor.status === "down") { - // Monitor went down (unreachable) - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (monitor.status === "breached") { - // Hardware monitor exceeded thresholds - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "threshold_breach"; - decision.notificationReason = "threshold_breach"; - } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { - // Monitor recovered from down or breached state - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - - return decision; - } -} diff --git a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts deleted file mode 100644 index 42409696b..000000000 --- a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import Scheduler from "super-simple-scheduler"; -import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; -import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; -import { hostname } from "node:os"; -import { randomUUID } from "node:crypto"; -import { QueueMode } from "@/types/settings.js"; -import { IJobQueue, type QueueJobSummary } from "@/service/infrastructure/JobQueues/IJobQueue.js"; - -const SERVICE_NAME = "JobQueue"; - -export class SuperSimpleQueue implements IJobQueue { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private helper: IQueueHelper; - private workerId: string; - private monitorsRepository: IMonitorsRepository; - private readonly scheduler: Scheduler; - - constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { - this.logger = logger; - this.helper = helper; - this.monitorsRepository = monitorsRepository; - this.scheduler = scheduler; - this.registerListeners(); - this.workerId = `${hostname()}:${process.pid}:${randomUUID()}`; - } - - get serviceName() { - return SuperSimpleQueue.SERVICE_NAME; - } - - private registerListeners = () => { - this.scheduler.on("scheduler:start", () => { - this.logger.info({ - message: "Scheduler started", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:stop", () => { - this.logger.info({ - message: "Scheduler stopped", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:error", (error) => { - this.logger.error({ - message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:abort", (job, reason) => { - this.logger.warn({ - message: `${job.id} aborted: ${reason}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:attempt", (job, attempt) => { - this.logger.debug({ - message: `${job.id} attempt ${attempt}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:complete", (job) => { - this.logger.debug({ - message: `${job.id} completed successfully`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:exhausted", (job, error) => { - this.logger.error({ - message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:fail", (job, error, attempt) => { - this.logger.warn({ - message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:start", (job) => { - this.logger.debug({ - message: `${job.id} started`, - service: SERVICE_NAME, - }); - }); - }; - - static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) { - const scheduler = new Scheduler({ - concurrency: 50, - // storeType: "mongo", - // storeType: "redis", - // dbUri: envSettings.dbConnectionString, - }); - const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); - await instance.init(); - - return instance; - } - - init = async () => { - try { - this.scheduler.start(); - this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); - this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); - this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); - this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); - const monitors = await this.monitorsRepository.findAll(); - if (!monitors) { - return true; - } - for (const monitor of monitors) { - const randomOffset = Math.floor(Math.random() * 100); - setTimeout(() => { - this.addJob(monitor.id, monitor); - }, randomOffset); - } - - this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); - this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 }); - - return true; - } catch (error: unknown) { - this.logger.error({ - message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - method: "init", - }); - return false; - } - }; - - addJob = async (monitorId: string, monitor: Monitor) => { - this.scheduler.addJob({ - id: monitorId, - template: "monitor-job", - repeat: monitor.interval, - active: monitor.isActive, - data: monitor, - }); - - // Return early if we don't need geo checks - if (!supportsGeoCheck(monitor.type)) { - return; - } - - // Add geo check job if enabled for HTTP monitors - if (monitor.geoCheckEnabled) { - this.scheduler.addJob({ - id: `${monitorId}-geo`, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - }); - } - }; - - deleteJob = async (monitor: Monitor) => { - this.scheduler.removeJob(monitor.id); - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); - }; - - pauseJob = async (monitor: Monitor) => { - const result = await this.scheduler.pauseJob(monitor.id); - if (result === false) { - throw new Error("Failed to pause monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to pause geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - - this.logger.debug({ - message: `Paused monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - }; - - resumeJob = async (monitor: Monitor) => { - const result = await this.scheduler.resumeJob(monitor.id); - if (result === false) { - throw new Error("Failed to resume monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to resume geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - this.logger.debug({ - message: `Resumed monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - }; - - private syncGeoJob = async (monitor: Monitor) => { - const geoJobId = `${monitor.id}-geo`; - const existingGeoJob = await this.scheduler.getJob(geoJobId); - - // If geoChecks have been disabled, or the monitor type doesn't support them, remove - if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { - if (existingGeoJob) this.scheduler.removeJob(geoJobId); - return; - } - - // If the job exists, update it - if (existingGeoJob) { - this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); - return; - } - - // Otherwise, create it - this.scheduler.addJob({ - id: geoJobId, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - }); - }; - - updateJob = async (monitor: Monitor) => { - this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); - await this.syncGeoJob(monitor); - }; - - shutdown = async () => { - this.scheduler.stop(); - }; - - getMetrics = async () => { - const jobs = await this.scheduler.getJobs(); - const metrics: { - jobs: number; - activeJobs: number; - failingJobs: number; - jobsWithFailures: Array<{ - monitorId: string | number; - monitorUrl: string; - monitorType: MonitorType; - failedAt: number | null; - failCount: number; - failReason: string | null; - }>; - totalRuns: number; - totalFailures: number; - workers: Array<{ - workerId: string; - mode: QueueMode; - lastSeenAt: number; - }>; - } = jobs.reduce( - (acc, job) => { - const runCount = job.runCount ?? 0; - const failCount = job.failCount ?? 0; - const lastFailedAt = job.lastFailedAt ?? 0; - const lastRunAt = job.lastRunAt ?? 0; - acc.totalRuns += runCount; - acc.totalFailures += failCount; - acc.jobs++; - if (failCount > 0 && lastFailedAt >= lastRunAt) { - acc.failingJobs++; - } - - if (job.lockedAt) { - acc.activeJobs++; - } - - if (failCount > 0) { - acc.jobsWithFailures.push({ - monitorId: job.id, - monitorUrl: job.data?.url || null, - monitorType: job.data?.type || null, - failedAt: job.lastFailedAt ?? null, - failCount, - failReason: job.lastFailReason ?? null, - }); - } - return acc; - }, - { - jobs: 0, - activeJobs: 0, - failingJobs: 0, - jobsWithFailures: [] as Array<{ - monitorId: string | number; - monitorUrl: string; - monitorType: MonitorType; - failedAt: number | null; - failCount: number; - failReason: string | null; - }>, - totalRuns: 0, - totalFailures: 0, - workers: [ - { - workerId: this.workerId, - mode: "primary", - lastSeenAt: Date.now(), - }, - ], - } - ); - return metrics; - }; - - getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: QueueJobSummary[]; count: number }> => { - const jobs = await this.scheduler.getJobs(); - const mapped = jobs.map((job) => { - return { - monitorId: job.id, - monitorUrl: job.data?.url || null, - monitorType: job.data?.type || null, - monitorInterval: job.data?.interval || null, - monitorGeoInterval: job.data?.geoCheckInterval || null, - monitorActive: job.data?.isActive ?? null, - active: job.active, - repeat: job.repeat ?? null, - lockedBy: job.lockedAt ? this.workerId : null, - lockedUntil: null, - lockedAt: job.lockedAt ?? null, - runCount: job.runCount ?? 0, - failCount: job.failCount ?? 0, - failReason: job.lastFailReason ?? null, - lastRunAt: job.lastRunAt ?? null, - lastFinishedAt: job.lastFinishedAt ?? null, - lastRunTook: job.lockedAt ? null : (job.lastFinishedAt ?? 0) - (job.lastRunAt ?? 0), - lastFailedAt: job.lastFailedAt ?? null, - }; - }); - - const start = Math.max(page, 0) * rowsPerPage; - return { - jobs: rowsPerPage > 0 ? mapped.slice(start, start + rowsPerPage) : mapped, - count: mapped.length, - }; - }; - - flushQueues = async () => { - const stopRes = await this.scheduler.stop(); - const flushRes = await this.scheduler.flushJobs(); - const initRes = await this.init(); - return { - success: Boolean(stopRes && flushRes && initRes), - }; - }; -} diff --git a/server/src/service/infrastructure/network/AdvancedMatcher.ts b/server/src/service/network/AdvancedMatcher.ts similarity index 96% rename from server/src/service/infrastructure/network/AdvancedMatcher.ts rename to server/src/service/network/AdvancedMatcher.ts index d6350ef63..8b25c53e8 100644 --- a/server/src/service/infrastructure/network/AdvancedMatcher.ts +++ b/server/src/service/network/AdvancedMatcher.ts @@ -1,4 +1,4 @@ -import { Monitor } from "@/types/monitor.js"; +import { Monitor } from "@/domain/monitors/monitor.types.js"; import jmespath from "jmespath"; type JmesPath = typeof jmespath; diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/network/DNSProvider.ts similarity index 91% rename from server/src/service/infrastructure/network/DNSProvider.ts rename to server/src/service/network/DNSProvider.ts index 92b4eb333..1aeff7e14 100644 --- a/server/src/service/infrastructure/network/DNSProvider.ts +++ b/server/src/service/network/DNSProvider.ts @@ -1,9 +1,9 @@ import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import type { Resolver } from "dns/promises"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js"; const SERVICE_NAME = "DNSProvider"; diff --git a/server/src/service/infrastructure/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts similarity index 95% rename from server/src/service/infrastructure/network/DockerProvider.ts rename to server/src/service/network/DockerProvider.ts index 1d4b98818..0cfb133af 100644 --- a/server/src/service/infrastructure/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,10 +1,10 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; import Dockerode from "dockerode"; -import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js"; type DockerodeType = typeof Dockerode; diff --git a/server/src/service/infrastructure/network/GameProvider.ts b/server/src/service/network/GameProvider.ts similarity index 89% rename from server/src/service/infrastructure/network/GameProvider.ts rename to server/src/service/network/GameProvider.ts index e2ace7433..4e063396d 100644 --- a/server/src/service/infrastructure/network/GameProvider.ts +++ b/server/src/service/network/GameProvider.ts @@ -1,9 +1,9 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { GameStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { GameDig } from "gamedig"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR } from "@/service/network/utils.js"; import { ILogger } from "@/utils/logger.js"; type GameDigType = typeof GameDig; diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/network/GrpcProvider.ts similarity index 96% rename from server/src/service/infrastructure/network/GrpcProvider.ts rename to server/src/service/network/GrpcProvider.ts index 14a063bca..4e79cef7f 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/network/GrpcProvider.ts @@ -1,8 +1,8 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { GrpcStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { AppError } from "@/utils/AppError.js"; -import { timeRequest } from "@/service/infrastructure/network/utils.js"; +import { timeRequest } from "@/service/network/utils.js"; import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; diff --git a/server/src/service/infrastructure/network/HardwareProvider.ts b/server/src/service/network/HardwareProvider.ts similarity index 79% rename from server/src/service/infrastructure/network/HardwareProvider.ts rename to server/src/service/network/HardwareProvider.ts index a9180381e..c612bdbad 100644 --- a/server/src/service/infrastructure/network/HardwareProvider.ts +++ b/server/src/service/network/HardwareProvider.ts @@ -1,7 +1,7 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; -import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; +import { HttpProvider } from "@/service/network/HttpProvider.js"; import { AppError } from "@/utils/AppError.js"; export class HardwareProvider implements IStatusProvider { diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts similarity index 50% rename from server/src/service/infrastructure/network/HttpProvider.ts rename to server/src/service/network/HttpProvider.ts index 247965cbf..2f08dab00 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -1,12 +1,12 @@ import { type Got, HTTPError, RequestError } from "got"; -import { IAdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js"; -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IAdvancedMatcher } from "@/service/network/AdvancedMatcher.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { HttpStatusPayload } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; import { Agent as HttpAgent } from "http"; -import { Monitor, MonitorType } from "@/types/monitor.js"; -import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; +import { NETWORK_ERROR, isStatusUp } from "@/service/network/utils.js"; import CacheableLookup from "cacheable-lookup"; export class HttpProvider implements IStatusProvider { @@ -40,21 +40,109 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } - private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { - if (error instanceof HTTPError || error instanceof RequestError) { + private buildResponse( + monitor: Monitor, + opts: { + body: string; + contentType: string; + statusCode: number; + statusUp: boolean; + message: string; + responseTime: number; + timings?: MonitorStatusResponse["timings"]; + } + ): MonitorStatusResponse { + const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts; + + // Return early for HEAD requests, no body to parse + if (monitor.method === "HEAD") { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: statusUp, + code: statusCode, + message, + responseTime, + timings, + payload: body as T | string, + }; + } + + const isJson = contentType.includes("application/json"); + + if (monitor.jsonPath && !isJson) { return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, - code: error.response?.statusCode ?? NETWORK_ERROR, - message: error.message, - responseTime: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0, - timings: error.timings, - payload: null as T, + code: statusCode, + message: "Response is not JSON", + responseTime, + timings, + payload: body, }; } + let payload: T | string; + if (isJson) { + try { + payload = JSON.parse(body) as T; + } catch { + payload = body; + } + } else { + payload = body; + } + + const matchResult = this.advancedMatcher.validate(payload, monitor); + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: statusUp && matchResult.ok, + code: statusCode, + message: matchResult.ok ? message : matchResult.message, + responseTime, + timings, + payload, + extracted: matchResult.extracted, + }; + } + + private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + if (error instanceof HTTPError || error instanceof RequestError) { + const statusCode = error.response?.statusCode; + const statusUp = isStatusUp(statusCode, monitor.customUpCodes); + const responseTime = error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0; + + if (!statusUp) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: statusCode ?? NETWORK_ERROR, + message: error.message, + responseTime, + timings: error.timings, + payload: null as T, + }; + } + + return this.buildResponse(monitor, { + body: (error.response?.body ?? "") as string, + contentType: error.response?.headers?.["content-type"] || "", + statusCode: statusCode ?? NETWORK_ERROR, + statusUp, + message: error.message, + responseTime, + timings: error.timings, + }); + } + return { monitorId: monitor.id, teamId: monitor.teamId, @@ -68,7 +156,7 @@ export class HttpProvider implements IStatusProvider { } async handle(monitor: Monitor): Promise> { - const { url, secret, jsonPath, ignoreTlsErrors } = monitor; + const { url, secret, ignoreTlsErrors } = monitor; if (!url) { throw new Error("URL is required for HTTP monitor"); @@ -83,49 +171,21 @@ export class HttpProvider implements IStatusProvider { https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, }; + options.method = monitor.method; + try { const response = await this.got(url, options); - const contentType = response.headers["content-type"] || ""; - const isJson = contentType.includes("application/json"); + const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes); - if (jsonPath && !isJson) { - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: response.statusCode, - message: "Response is not JSON", - responseTime: response.timings.phases.firstByte ?? 0, - timings: response.timings, - payload: response.body as unknown as T, - }; - } - - let payload: T; - if (isJson) { - try { - payload = JSON.parse(response.body) as T; - } catch { - payload = response.body as unknown as T; - } - } else { - payload = response.body as unknown as T; - } - - const matchResult = this.advancedMatcher.validate(payload, monitor); - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: response.ok && matchResult.ok, - code: response.statusCode, - message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, + return this.buildResponse(monitor, { + body: response.body, + contentType: response.headers["content-type"] || "", + statusCode: response.statusCode, + statusUp, + message: response.statusMessage ?? "OK", responseTime: response.timings.phases.firstByte ?? 0, timings: response.timings, - payload, - extracted: matchResult.extracted, - }; + }); } catch (error: unknown) { return this.handleHttpError(error, monitor); } diff --git a/server/src/service/infrastructure/network/IStatusProvider.ts b/server/src/service/network/IStatusProvider.ts similarity index 74% rename from server/src/service/infrastructure/network/IStatusProvider.ts rename to server/src/service/network/IStatusProvider.ts index bde6a2db0..f077ff9b5 100644 --- a/server/src/service/infrastructure/network/IStatusProvider.ts +++ b/server/src/service/network/IStatusProvider.ts @@ -1,4 +1,4 @@ -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { MonitorStatusResponse } from "@/types/network.js"; export interface IStatusProvider { diff --git a/server/src/service/infrastructure/network/PageSpeedProvider.ts b/server/src/service/network/PageSpeedProvider.ts similarity index 87% rename from server/src/service/infrastructure/network/PageSpeedProvider.ts rename to server/src/service/network/PageSpeedProvider.ts index 0d8560868..a609ca80b 100644 --- a/server/src/service/infrastructure/network/PageSpeedProvider.ts +++ b/server/src/service/network/PageSpeedProvider.ts @@ -1,8 +1,8 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js"; -import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/types/monitor.js"; -import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; -import { ISettingsService } from "@/service/system/settingsService.js"; +import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/domain/monitors/monitor.types.js"; +import { HttpProvider } from "@/service/network/HttpProvider.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/service/infrastructure/network/PingProvider.ts b/server/src/service/network/PingProvider.ts similarity index 92% rename from server/src/service/infrastructure/network/PingProvider.ts rename to server/src/service/network/PingProvider.ts index 0b9691da9..ee66046f9 100644 --- a/server/src/service/infrastructure/network/PingProvider.ts +++ b/server/src/service/network/PingProvider.ts @@ -1,10 +1,10 @@ import { PingStatusPayload } from "@/types/network.js"; import { IStatusProvider } from "./IStatusProvider.js"; -import { MonitorType, Monitor } from "@/types/monitor.js"; +import { MonitorType, Monitor } from "@/domain/monitors/monitor.types.js"; import { MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import ping from "ping"; -import { timeRequest } from "@/service/infrastructure/network/utils.js"; +import { timeRequest } from "@/service/network/utils.js"; const SERVICE_NAME = "PingProvider"; type Ping = typeof ping; diff --git a/server/src/service/infrastructure/network/PortProvider.ts b/server/src/service/network/PortProvider.ts similarity index 90% rename from server/src/service/infrastructure/network/PortProvider.ts rename to server/src/service/network/PortProvider.ts index 7c1c96ce4..debcd8509 100644 --- a/server/src/service/infrastructure/network/PortProvider.ts +++ b/server/src/service/network/PortProvider.ts @@ -1,8 +1,8 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { PortStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js"; import * as net from "net"; type NetType = typeof net; diff --git a/server/src/service/infrastructure/network/WebSocketProvider.ts b/server/src/service/network/WebSocketProvider.ts similarity index 91% rename from server/src/service/infrastructure/network/WebSocketProvider.ts rename to server/src/service/network/WebSocketProvider.ts index 0af436067..fef5f76b1 100644 --- a/server/src/service/infrastructure/network/WebSocketProvider.ts +++ b/server/src/service/network/WebSocketProvider.ts @@ -1,8 +1,8 @@ -import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { WebSocketStatusPayload, MonitorStatusResponse } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/network/utils.js"; import type WebSocket from "ws"; type WebSocketConstructor = typeof WebSocket; diff --git a/server/src/service/infrastructure/network/utils.ts b/server/src/service/network/utils.ts similarity index 65% rename from server/src/service/infrastructure/network/utils.ts rename to server/src/service/network/utils.ts index ddb962f47..8bcbc4335 100644 --- a/server/src/service/infrastructure/network/utils.ts +++ b/server/src/service/network/utils.ts @@ -1,3 +1,5 @@ +import type { HttpStatusCode } from "@/domain/monitors/monitor.types.js"; + export const timeRequest = async (operation: () => Promise): Promise<{ response: T | null; responseTime: number; error: unknown }> => { const start = process.hrtime.bigint(); try { @@ -12,3 +14,8 @@ export const timeRequest = async (operation: () => Promise): Promise<{ res export const NETWORK_ERROR = 5000; export const PING_ERROR = 5001; + +export const isStatusUp = (statusCode: number | undefined, customUpCodes: HttpStatusCode[] = []): boolean => { + if (statusCode === undefined) return false; + return (statusCode >= 200 && statusCode < 300) || customUpCodes.includes(statusCode); +}; diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/networkService.ts similarity index 96% rename from server/src/service/infrastructure/networkService.ts rename to server/src/service/networkService.ts index ca85d12ac..3470c6086 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/networkService.ts @@ -1,7 +1,8 @@ -import type { Monitor, MonitorStatusResponse, MonitorPayloadMap, MonitorType } from "@/types/index.js"; +import type { Monitor, MonitorType } from "@/domain/monitors/monitor.types.js"; +import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; -import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; +import { NETWORK_ERROR } from "@/service/network/utils.js"; import { ILogger } from "@/utils/logger.js"; import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; diff --git a/server/src/service/infrastructure/protos/health.proto b/server/src/service/protos/health.proto similarity index 100% rename from server/src/service/infrastructure/protos/health.proto rename to server/src/service/protos/health.proto diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/statusService.ts similarity index 90% rename from server/src/service/infrastructure/statusService.ts rename to server/src/service/statusService.ts index 617f15944..ccda0c5d7 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/statusService.ts @@ -1,26 +1,23 @@ -import { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository } from "@/repositories/index.js"; +import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.types.js"; import type { - Monitor, - MonitorStatus, - MonitorStatusResponse, - StatusChangeResult, - Check, - HardwareStatusPayload, - PageSpeedStatusPayload, - PingStatusPayload, - HttpStatusPayload, DockerStatusPayload, - PortStatusPayload, GameStatusPayload, GrpcStatusPayload, - CheckSnapshot, - CheckDiskInfo, -} from "@/types/index.js"; + HardwareStatusPayload, + HttpStatusPayload, + MonitorStatusResponse, + PageSpeedStatusPayload, + PingStatusPayload, + PortStatusPayload, + StatusChangeResult, +} from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; -import { IBufferService } from "./bufferService.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; -import { MAX_RECENT_CHECKS } from "@/types/index.js"; +import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.types.js"; const SERVICE_NAME = "StatusService"; const HARDWARE_ALERT_COUNTER_START = 5; @@ -43,30 +40,21 @@ export interface IStatusService { | GrpcStatusPayload | undefined >, - check: Check + check: Check, + monitor: Monitor ): Promise; } export class StatusService implements IStatusService { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; - private buffer: IBufferService; private monitorsRepository: IMonitorsRepository; private monitorStatsRepository: IMonitorStatsRepository; - private checksRepository: IChecksRepository; - constructor( - logger: ILogger, - buffer: IBufferService, - monitorsRepository: IMonitorsRepository, - monitorStatsRepository: IMonitorStatsRepository, - checksRepository: IChecksRepository - ) { + constructor(logger: ILogger, monitorsRepository: IMonitorsRepository, monitorStatsRepository: IMonitorStatsRepository) { this.logger = logger; - this.buffer = buffer; this.monitorsRepository = monitorsRepository; this.monitorStatsRepository = monitorStatsRepository; - this.checksRepository = checksRepository; } get serviceName() { @@ -213,11 +201,11 @@ export class StatusService implements IStatusService { | GrpcStatusPayload | undefined >, - check: Check + check: Check, + monitor: Monitor ): Promise => { try { - const { monitorId, teamId, status, code } = statusResponse; - const monitor = await this.monitorsRepository.findById(monitorId, teamId); + const { status, code } = statusResponse; // Update running stats await this.tryUpdateRunningStats(monitor, statusResponse); @@ -233,10 +221,10 @@ export class StatusService implements IStatusService { // Build the status patch — computed against the projected window const patch: Partial = {}; - // Resolve "initializing" up-front, incidents should be created on initialization && down + // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing") { + if (monitor.status === "initializing" || monitor.status === "maintenance") { newStatus = status === true ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; @@ -263,7 +251,7 @@ export class StatusService implements IStatusService { }; } - // First evaluate reachability-based status changes, which apply to all monitor types + // First evaluate reachability status changes, which apply to all monitor types // and take precedence over hardware breaches. const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold); if (reachabilityResult.transitioned) { diff --git a/server/src/shutdown.ts b/server/src/shutdown.ts index 3c1fc4ee7..a25d96c0e 100644 --- a/server/src/shutdown.ts +++ b/server/src/shutdown.ts @@ -1,9 +1,19 @@ -import { InitializedServices } from "./config/services.js"; -import { logger } from "./utils/logger.js"; +import { IDb } from "@/db/db.interface.js"; +import type { ILogger } from "@/utils/logger.js"; import type { Server } from "http"; +import { Mongoose } from "mongoose"; +import { IJobScheduler } from "@/worker/worker.interface.js"; +import { IHealthServer } from "@/worker/worker.health-server.js"; -export const initShutdownListener = (server: Server | null, services: InitializedServices) => { +type ShutdownTargets = { + worker: IJobScheduler; + db: IDb; + logger: ILogger; + healthServer?: IHealthServer; +}; +export const initShutdownListener = (server: Server | null, shutdownTargets: ShutdownTargets) => { const SERVICE_NAME = "Server"; + const logger = shutdownTargets.logger; let isShuttingDown = false; @@ -16,8 +26,10 @@ export const initShutdownListener = (server: Server | null, services: Initialize try { server?.close(); - await services.jobQueue.shutdown(); - await services.db.disconnect(); + await shutdownTargets.worker.drain(); + await shutdownTargets.worker.shutdown(); + await shutdownTargets.healthServer?.close(); + await shutdownTargets.db.disconnect(); logger.info({ message: "Graceful shutdown complete" }); process.exit(0); } catch (error) { diff --git a/server/src/types/alert.ts b/server/src/types/alert.ts deleted file mode 100644 index 3cb9d7734..000000000 --- a/server/src/types/alert.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NotificationChannel } from "@/types/index.js"; -export interface AlertWebhookPayload { - body: Record; -} - -export interface AlertPagerDutyPayload { - routing_key?: string; - dedup_key?: string; - event_action?: "trigger" | "resolve"; - payload: Record; -} - -export interface AlertMatrixPayload { - plainText: string; - htmlText: string; -} - -export interface DiscordEmbedField { - name: string; - value: string; - inline?: boolean; -} - -export interface AlertDiscordPayload { - title: string; - description: string; - color: number; - fields: DiscordEmbedField[]; - timestamp: string; -} - -export interface Alert { - channel: NotificationChannel; - address?: string; - subject: string; - message: string; - html?: string; - discordContent?: Record | null; - webhook?: AlertWebhookPayload | null; - pagerDuty?: AlertPagerDutyPayload | null; - matrix?: AlertMatrixPayload | null; -} diff --git a/server/src/types/email.ts b/server/src/types/email.ts deleted file mode 100644 index 6e884319f..000000000 --- a/server/src/types/email.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Settings } from "./settings.js"; - -export type EmailTransportConfig = Pick< - Settings, - | "systemEmailHost" - | "systemEmailPort" - | "systemEmailAddress" - | "systemEmailDisplayName" - | "systemEmailPassword" - | "systemEmailUser" - | "systemEmailConnectionHost" - | "systemEmailTLSServername" - | "systemEmailSecure" - | "systemEmailPool" - | "systemEmailIgnoreTLS" - | "systemEmailRequireTLS" - | "systemEmailRejectUnauthorized" ->; diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 973d2d5b9..92a4a86c6 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -1,4 +1,4 @@ -import type { User } from "@/types/index.js"; +import type { User } from "@/domain/users/user.type.js"; declare global { namespace Express { diff --git a/server/src/types/index.ts b/server/src/types/index.ts deleted file mode 100644 index ca055ba70..000000000 --- a/server/src/types/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export * from "@/types/check.js"; -export * from "@/types/geoCheck.js"; -export * from "@/types/monitor.js"; -export * from "@/types/monitorStats.js"; -export * from "@/types/statusPage.js"; -export * from "@/types/network.js"; -export * from "@/types/user.js"; -export * from "@/types/invite.js"; -export * from "@/types/recoveryToken.js"; -export * from "@/types/settings.js"; -export * from "@/types/notification.js"; -export * from "@/types/alert.js"; -export * from "@/types/incident.js"; -export * from "@/types/email.js"; -export * from "@/types/team.js"; -export * from "@/types/maintenanceWindow.js"; -export * from "@/types/dlqItem.js"; -export * from "@/types/tag.js"; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 19f063cc6..fe85221f2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -8,12 +8,8 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, - DnsRecordType, - Monitor, - MonitorMatchMethod, - MonitorType, - MonitorStatus, -} from "@/types/index.js"; +} from "@/domain/checks/check.type.js"; +import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.types.js"; import type { QueryResult } from "gamedig"; @@ -35,7 +31,7 @@ export interface MonitorStatusResponse< code: number; message: string; responseTime?: number; - payload?: T | null; + payload?: T | string | null; timings?: GotTimings; first_byte_took?: number; body_read_took?: number; diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts deleted file mode 100644 index 0e389f9e2..000000000 --- a/server/src/types/notification.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const NotificationChannels = [ - "email", - "slack", - "discord", - "webhook", - "pager_duty", - "matrix", - "teams", - "telegram", - "pushover", - "twilio", - "ntfy", -] as const; -export type NotificationChannel = (typeof NotificationChannels)[number]; - -export interface Notification { - id: string; - userId: string; - teamId: string; - type: NotificationChannel; - notificationName: string; - address?: string; - phone?: string; - homeserverUrl?: string; - roomId?: string; - accessToken?: string; - accountSid?: string; - twilioPhoneNumber?: string; - topic?: string; - createdAt: string; - updatedAt: string; -} diff --git a/server/src/types/upload.ts b/server/src/types/upload.ts new file mode 100644 index 000000000..355688839 --- /dev/null +++ b/server/src/types/upload.ts @@ -0,0 +1,5 @@ +// Shared constraints for user supplied image uploads +export const ImageMimeTypes = ["image/jpeg", "image/png", "image/jpg"] as const; +export type ImageMimeType = (typeof ImageMimeTypes)[number]; + +export const MAX_IMAGE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts new file mode 100644 index 000000000..3f38a0bd5 --- /dev/null +++ b/server/src/utils/customCss.ts @@ -0,0 +1,14 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +// Tokenizing resolves comments, escapes, and line continuations so obfuscated +// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set(). +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)); +}; diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index f6d877a35..7b46619e8 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,4 +1,4 @@ -import type { GroupedCheck, NormalizedCheck, NormalizedUptimeCheck, HasResponseTime } from "@/types/index.js"; +import type { GroupedCheck, HasResponseTime, NormalizedCheck, NormalizedUptimeCheck } from "@/domain/checks/check.type.js"; export const getDateForRange = (dateRange: string): Date | undefined => { const now = Date.now(); diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index e3c0db169..41142fd92 100755 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -1,7 +1,6 @@ import { createLogger, format, transports, Logger as WinstonLogger } from "winston"; import type { Logform } from "winston"; import dotenv from "dotenv"; -import { EnvConfig } from "@/service/system/settingsService.js"; dotenv.config(); const SERVICE_NAME = "Logger"; @@ -28,18 +27,17 @@ export interface ILogger { cacheLog(entry: LogEntry): void; getLogs(): LogEntry[]; buildLogEntry(level: string, config: LogConfig): LogEntry; + setLogLevel(level: string): void; } class Logger implements ILogger { static SERVICE_NAME = SERVICE_NAME; private logger: WinstonLogger; - private envSettings: Partial; private logCache: LogEntry[]; private maxCacheSize: number; - constructor({ envSettings }: { envSettings: Partial }) { - this.envSettings = envSettings; + constructor() { this.logCache = []; this.maxCacheSize = 1000; const consoleFormat = format.printf((info: Logform.TransformableInfo) => { @@ -84,10 +82,8 @@ class Logger implements ILogger { return msg; }); - const logLevel = this.envSettings.logLevel || "info"; - this.logger = createLogger({ - level: logLevel, + level: "info", format: format.combine(format.timestamp()), transports: [ new transports.Console({ @@ -151,11 +147,10 @@ class Logger implements ILogger { timestamp: new Date().toISOString(), }; } + + setLogLevel(level: string): void { + this.logger.level = level || "info"; + } } export default Logger; - -// Legacy logger -const logger = new Logger({ envSettings: { logLevel: "debug" } }); -export { logger }; -export type { LogConfig, LogEntry }; diff --git a/server/src/utils/maintenanceWindow.ts b/server/src/utils/maintenanceWindow.ts index aab4efd64..67351639d 100644 --- a/server/src/utils/maintenanceWindow.ts +++ b/server/src/utils/maintenanceWindow.ts @@ -1,4 +1,4 @@ -import type { MaintenanceWindow } from "@/types/index.js"; +import type { MaintenanceWindow } from "@/domain/maintenance-windows/maintenance-window.type.js"; export const isWindowActive = (window: MaintenanceWindow, now: Date = new Date()): boolean => { if (!window.active) { diff --git a/server/src/utils/statusPageDomain.ts b/server/src/utils/statusPageDomain.ts new file mode 100644 index 000000000..ec730a806 --- /dev/null +++ b/server/src/utils/statusPageDomain.ts @@ -0,0 +1,29 @@ +export const normalizeStatusPageDomain = (value?: string | null): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + // Strip scheme, path, and port if a full URL was pasted. + const withoutScheme = trimmed.replace(/^https?:\/\//, ""); + const hostname = withoutScheme.split("/")[0]?.split(":")[0] ?? ""; + + return hostname || null; +}; + +export const resolveStatusPageDomainFromRequest = (hostHeader?: string | null, queryDomain?: string | null): string | null => { + const fromQuery = normalizeStatusPageDomain(queryDomain); + if (fromQuery) { + return fromQuery; + } + + if (!hostHeader) { + return null; + } + + return normalizeStatusPageDomain(hostHeader.split(":")[0]); +}; diff --git a/server/src/worker/reactors/reactor.dispatcher.ts b/server/src/worker/reactors/reactor.dispatcher.ts new file mode 100644 index 000000000..a4ffe757e --- /dev/null +++ b/server/src/worker/reactors/reactor.dispatcher.ts @@ -0,0 +1,39 @@ +import { ILogger } from "@/utils/logger.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; + +const SERVICE_NAME = "ReactorDispatcher"; + +export interface IReactorDispatcher { + dispatch(evaluation: MonitorEvaluation): Promise; +} + +export class ReactorDispatcher implements IReactorDispatcher { + constructor( + private logger: ILogger, + private reactors: IMonitorReactor[] + ) {} + + private logFailure(reactor: IMonitorReactor, evaluation: MonitorEvaluation, error: unknown) { + this.logger.error({ + service: SERVICE_NAME, + method: "dispatch", + message: `Reactor ${reactor.name} failed for monitor ${evaluation.monitor.id}: ${error instanceof Error ? error.message : String(error)}`, + stack: error instanceof Error ? error.stack : undefined, + }); + } + + dispatch = async (evaluation: MonitorEvaluation) => { + for (const reactor of this.reactors) { + if (reactor.blocking) { + try { + await reactor.react(evaluation); + } catch (error: unknown) { + this.logFailure(reactor, evaluation, error); + } + } else { + reactor.react(evaluation).catch((error: unknown) => this.logFailure(reactor, evaluation, error)); + } + } + }; +} diff --git a/server/src/worker/reactors/reactor.incident.ts b/server/src/worker/reactors/reactor.incident.ts new file mode 100644 index 000000000..3679a30cf --- /dev/null +++ b/server/src/worker/reactors/reactor.incident.ts @@ -0,0 +1,13 @@ +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IIncidentService } from "@/domain/incidents/incident.service.js"; + +export class IncidentReactor implements IMonitorReactor { + readonly name = "incident"; + readonly blocking = true; + constructor(private incidentService: IIncidentService) {} + + react = async (evaluation: MonitorEvaluation) => { + await this.incidentService.handleIncident(evaluation.monitor, evaluation.statusChange.code, evaluation.decision, evaluation.status); + }; +} diff --git a/server/src/worker/reactors/reactor.interface.ts b/server/src/worker/reactors/reactor.interface.ts new file mode 100644 index 000000000..f9ed6e436 --- /dev/null +++ b/server/src/worker/reactors/reactor.interface.ts @@ -0,0 +1,7 @@ +import { MonitorEvaluation } from "@/worker/worker.interface.js"; + +export interface IMonitorReactor { + readonly name: string; + readonly blocking: boolean; + react(evaluation: MonitorEvaluation): Promise; +} diff --git a/server/src/worker/reactors/reactor.notification.ts b/server/src/worker/reactors/reactor.notification.ts new file mode 100644 index 000000000..1daf528f4 --- /dev/null +++ b/server/src/worker/reactors/reactor.notification.ts @@ -0,0 +1,13 @@ +import { INotificationsService } from "@/domain/notifications/notification.service.js"; +import { IMonitorReactor } from "@/worker/reactors/reactor.interface.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +export class NotificationReactor implements IMonitorReactor { + readonly name = "notification"; + readonly blocking = false; + constructor(private notificationService: INotificationsService) {} + + react = async (evaluation: MonitorEvaluation) => { + if (!evaluation.decision.shouldSendNotification) return; + await this.notificationService.handleNotifications(evaluation.monitor, evaluation.status, evaluation.decision); + }; +} diff --git a/server/src/worker/worker.check-evaluator.ts b/server/src/worker/worker.check-evaluator.ts new file mode 100644 index 000000000..34c444271 --- /dev/null +++ b/server/src/worker/worker.check-evaluator.ts @@ -0,0 +1,34 @@ +import { MonitorStatusResponse } from "@/types/network.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { Check } from "@/domain/checks/check.type.js"; +import { Monitor } from "@/domain/monitors/monitor.types.js"; +import { IMonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js"; +import { IStatusService } from "@/service/statusService.js"; + +export interface ICheckEvaluator { + evaluate(status: MonitorStatusResponse, check: Check, monitor: Monitor): Promise; +} + +export class CheckEvaluator implements ICheckEvaluator { + constructor( + private statusService: IStatusService, + private monitorStatusPolicy: IMonitorStatusPolicy + ) {} + evaluate = async (status: MonitorStatusResponse, check: Check, monitor: Monitor) => { + // **************************** + // Step 3: Evaluate and return result to reactors + // **************************** + const statusChangeResult = await this.statusService.updateMonitorStatus(status, check, monitor); + + // Step 5. Get decisions and create an evaluation obj + const decision = this.monitorStatusPolicy.evaluate(statusChangeResult); + const evaluation: MonitorEvaluation = { + monitor: statusChangeResult.monitor, + status, + check, + statusChange: statusChangeResult, + decision, + }; + return evaluation; + }; +} diff --git a/server/src/worker/worker.check-pipeline.ts b/server/src/worker/worker.check-pipeline.ts new file mode 100644 index 000000000..045cea1ad --- /dev/null +++ b/server/src/worker/worker.check-pipeline.ts @@ -0,0 +1,99 @@ +import { Monitor } from "@/domain/monitors/monitor.types.js"; +import { MonitorEvaluation } from "@/worker/worker.interface.js"; +import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { ILogger } from "@/utils/logger.js"; +import { IBufferService } from "@/service/bufferService.js"; +import { AppError } from "@/utils/AppError.js"; +import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; +import { IGeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; + +const SERVICE_NAME = "CheckPipeline"; + +export interface ICheckPipeline { + run(monitor: Monitor): Promise; // null = skipped +} + +export class GeoChecksPipeline implements ICheckPipeline { + constructor( + private maintenanceWindowsRepository: IMaintenanceWindowsRepository, + private geoChecksService: IGeoChecksService, + private bufferService: IBufferService, + private logger: ILogger + ) {} + + private async isInMaintenanceWindow(monitorId: string, teamId: string) { + const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); + } + + run = async (monitor: Monitor): Promise => { + // **************************** + // Step 1: Acquire + // **************************** + + // Step 1a: Guards - skip if unsupported or not enabled + + if (!monitor.id) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); + } + + if (!monitor.geoCheckEnabled) { + return null; + } + if (!supportsGeoCheck(monitor.type)) { + this.logger.debug({ + message: `Monitor ${monitor.id} type does not support geo checks, skipping`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: `No geo check locations configured for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // Step 1b: Maintenance window check + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitor.id} is in maintenance window, skipping geo check`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // **************************** + // Step 2: Record + // **************************** + + // Step 2a: Build geo check, return null if fail + const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); + if (!geoCheck) { + this.logger.warn({ + message: `No geo check could be built for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + } + + // Step 2b: Add to buffer + this.bufferService.addGeoCheckToBuffer(geoCheck); + + this.logger.debug({ + message: `Geo check job executed for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "runGeoChecksPipeline", + }); + return null; + }; +} diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts new file mode 100644 index 000000000..3365ea93b --- /dev/null +++ b/server/src/worker/worker.check-producer.ts @@ -0,0 +1,83 @@ +import { Monitor } from "@/domain/monitors/monitor.types.js"; +import { Check } from "@/domain/checks/check.type.js"; +import { MonitorStatusResponse } from "@/types/network.js"; +import { AppError } from "@/utils/AppError.js"; +import { ILogger } from "@/utils/logger.js"; +import { IBufferService } from "@/service/bufferService.js"; +import { INetworkService } from "@/service/networkService.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; + +export interface ICheckProducer { + produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; +} + +const SERVICE_NAME = "CheckProducer"; + +export class CheckProducer implements ICheckProducer { + constructor( + private monitorsRepository: IMonitorsRepository, + private maintenanceWindowsRepository: IMaintenanceWindowsRepository, + private checkService: ICheckService, + private networkService: INetworkService, + private bufferService: IBufferService, + private logger: ILogger + ) {} + + private async isInMaintenanceWindow(monitorId: string, teamId: string) { + const windows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); + const now = new Date(); + return windows.some((w) => isWindowActive(w, now)); + } + + produce = async (monitor: Monitor) => { + if (!monitor.id) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" }); + } + // **************************** + // Step 1: Acquire + // **************************** + + // Step 1a: Maintenance window gate - skip if in maintenance + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitor.id, monitor.teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitor.id} is in maintenance window`, + service: SERVICE_NAME, + method: "produce", + }); + if (monitor.status !== "maintenance") { + // Clear the status window to avoid incidents being created on next check + await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance", statusWindow: [] }); + } + return null; + } + + // Step 1b: Acquire status + const status = await this.networkService.requestStatus(monitor); + if (!status) { + throw new Error("No network response"); + } + + // **************************** + // Step 2: Record + // **************************** + + // Step 2a: Create & record a check, return null if fail + const check = this.checkService.toCheck(status); + if (!check) { + this.logger.warn({ + message: `No check could be built for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "produce", + details: { code: status.code, message: status.message }, + }); + return null; + } + // Step 2b: Add to buffer + this.bufferService.addToBuffer(check); + return { status, check }; + }; +} diff --git a/server/src/worker/worker.db-queue.ts b/server/src/worker/worker.db-queue.ts new file mode 100644 index 000000000..cdcb2de44 --- /dev/null +++ b/server/src/worker/worker.db-queue.ts @@ -0,0 +1,312 @@ +import { IQueueWorker } from "@/worker/worker.interface.js"; +import { type Job, type JobType, LOCK_MS } from "@/domain/jobs/job.type.js"; +import { ILogger } from "@/utils/logger.js"; +import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { ICheckProducer } from "@/worker/worker.check-producer.js"; +import { ICheckEvaluator } from "@/worker/worker.check-evaluator.js"; +import { ICheckPipeline } from "@/worker/worker.check-pipeline.js"; +import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; +import { IWorkerHelper } from "@/worker/worker.helper.js"; +import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; +import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js"; +import { QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import { JobScheduler } from "@/worker/worker.job-scheduler.js"; +import { IBufferService } from "@/service/bufferService.js"; +const SERVICE_NAME = "JobQueue"; +const POLL_MS = 250; // base poll interval while a loop is actively claiming work +const POLL_MAX_MS = 5000; // back off poll interval to 5s if no jobs +const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window +const LOCK_RENEW_MS = LOCK_MS / 3; // renew an in-flight job's lock at 1/3 the lease, so it survives two missed renewals +const DRAIN_TIMEOUT_MS = 25_000; +const DRAIN_POLL_MS = 200; + +const CONCURRENCY: Record = { + check: 500, + "geo-check": 20, + evaluate: 20, + "cleanup-orphaned": 1, + "cleanup-retention": 1, +}; + +export class DBQueueWorker extends JobScheduler implements IQueueWorker { + static SERVICE_NAME = SERVICE_NAME; + + private initComplete: boolean = false; + private lastTickAt: number | null = null; + + private inFlight: Record = { + check: 0, + "geo-check": 0, + evaluate: 0, + "cleanup-orphaned": 0, + "cleanup-retention": 0, + }; + + constructor( + logger: ILogger, // forwarded to super + private isDbConnected: () => boolean, + jobsRepository: IJobsRepository, // forwarded + monitorsRepository: IMonitorsRepository, // forwarded + private checksRepository: IChecksRepository, + private checkService: ICheckService, + private bufferService: IBufferService, + private checkProducer: ICheckProducer, + private checkEvaluator: ICheckEvaluator, + private geoCheckPipeline: ICheckPipeline, + private dispatcher: IReactorDispatcher, + private helper: IWorkerHelper, + queueWorkersRepository: IQueueWorkersRepository, // forwarded + queueMode: QueueMode, // forwarded + queuePrimaryProcesses: boolean, // consumed immediately + workerId: string + ) { + super(jobsRepository, queueWorkersRepository, monitorsRepository, queueMode, logger, workerId); + this.processesJobs = queuePrimaryProcesses === true || queueMode === "worker"; + } + + get serviceName() { + return DBQueueWorker.SERVICE_NAME; + } + + static async create( + logger: ILogger, + isDbConnected: () => boolean, + jobsRepository: IJobsRepository, + monitorsRepository: IMonitorsRepository, + checksRepository: IChecksRepository, + checkService: ICheckService, + bufferService: IBufferService, + checkProducer: ICheckProducer, + checkEvaluator: ICheckEvaluator, + geoCheckPipeline: ICheckPipeline, + dispatcher: IReactorDispatcher, + helper: IWorkerHelper, + queueWorkersRepository: IQueueWorkersRepository, + queueMode: QueueMode, + queuePrimaryProcesses: boolean, + workerId: string + ): Promise { + const instance = new DBQueueWorker( + logger, + isDbConnected, + jobsRepository, + monitorsRepository, + checksRepository, + checkService, + bufferService, + checkProducer, + checkEvaluator, + geoCheckPipeline, + dispatcher, + helper, + queueWorkersRepository, + queueMode, + queuePrimaryProcesses, + workerId + ); + await instance.init(); + return instance; + } + + private getInFlightCount = () => Object.values(this.inFlight).reduce((sum, n) => sum + n, 0); + + // ******************** + // Stage 1: This can eventually be offloaded to a separate service + // ******************** + + private runCheck = async (job: Job) => { + if (!job.refId) return; + const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId + if (!monitor) return; + await this.checkProducer.produce(monitor); + }; + + // ******************** + // Stage 2: Evaluator loop stays here + // ******************** + + private runEvaluate = async (job: Job) => { + if (!job.refId) return; + const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId + if (!monitor) return; + const checks = await this.checksRepository.findUnevaluatedByMonitorId(job.refId, monitor.lastEvaluatedAt); + + let current = monitor; + for (const check of checks) { + const status = this.checkService.toStatusResponse(check); + const evaluation = await this.checkEvaluator.evaluate(status, check, current); + await this.dispatcher.dispatch(evaluation); // Handle incidents and notifications + await this.monitorsRepository.updateById(job.refId, current.teamId, { lastEvaluatedAt: this.checkService.toLastEvaluatedAt(check) }); + current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check + } + }; + + private runGeoCheck = async (job: Job) => { + const monitor = await this.monitorsRepository.findByIdLean(job.refId!); + if (monitor) await this.geoCheckPipeline.run(monitor); // returns null; no evaluate handoff + }; + + // Extend the lock while a job runs so a slow-but-alive job is never reclaimed. + private renewLock = async (job: Job): Promise => { + try { + const held = await this.jobsRepository.renewLocks([job.id], Date.now()); + if (held === 0) { + this.logger.warn({ message: `Lost lock on job ${job.id}, stopping renewal`, service: SERVICE_NAME, method: "renewLock" }); + return false; + } + return true; + } catch (error: unknown) { + this.logger.warn({ message: error instanceof Error ? error.message : String(error), service: SERVICE_NAME, method: "renewLock" }); + return true; + } + }; + + private runJob = async (job: Job) => { + const renewTimer = setInterval(async () => { + const renewLock = await this.renewLock(job); + if (!renewLock) clearInterval(renewTimer); + }, LOCK_RENEW_MS); + try { + switch (job.type) { + case "check": + await this.runCheck(job); + break; + case "evaluate": + await this.runEvaluate(job); + break; + case "geo-check": + await this.runGeoCheck(job); + break; + case "cleanup-orphaned": + await this.helper.getCleanupOrphanedJob()(); // Get job and execute + break; + case "cleanup-retention": + await this.helper.getCleanupRetentionJob()(); // Get job and execute + break; + } + + if (job.intervalMs === null) { + // One-shot job + await this.jobsRepository.recordOneShot(job.id, Date.now()); + } else { + await this.jobsRepository.recordSuccess(job.id, job.nextScheduledAt, job.intervalMs, Date.now()); + } + } catch (error: unknown) { + await this.jobsRepository.recordFailure(job.id, error, Date.now()); // Record failure and release lock + this.logger.warn({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: `runJob:${job.type}`, + }); + } finally { + clearInterval(renewTimer); + } + }; + + private startLoop = (type: JobType) => { + this.pollMs[type] = POLL_MS; + const tick = async () => { + this.lastTickAt = Date.now(); + if (this.stopped) return; + this.ticking[type] = true; + try { + // Claim a batch sized to the free capacity + const capacity = CONCURRENCY[type] - this.inFlight[type]; + const jobs = await this.jobsRepository.claimDueBatch(type, capacity, Date.now()); + for (const job of jobs) { + this.inFlight[type]++; // stay within concurrency limits + this.runJob(job).finally(() => { + this.inFlight[type]--; // job done, free the slot + }); + } + // Back off if no jobs found, reset to base if jobs found + this.pollMs[type] = capacity > 0 && jobs.length === 0 ? Math.min(this.pollMs[type] * 2, POLL_MAX_MS) : POLL_MS; + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: `loop:${type}`, + }); + } + // Clearing the flag and re-arming run synchronously (no await between), so wake() can never interleave here. + this.ticking[type] = false; + if (!this.stopped) this.timers.set(type, setTimeout(tick, this.pollMs[type])); // re-arm unless shutting down + }; + this.tickFns.set(type, tick); + tick(); + }; + + // Wake a worker loop after adding a job that's due immediately. + // Otherwise the loop could be delayed by POLL_MAX_MS if it is already fully backed off + + override async init() { + await super.init(); // heartbeat + (primary?) reconcile + if (this.processesJobs) { + for (const type of Object.keys(this.inFlight) as JobType[]) { + this.startLoop(type); + } + } + this.initComplete = true; + return true; + } + + override flushQueues = async () => { + await this.shutdown(); + const ok = await this.init(); + return { success: ok }; + }; + + override drain = async () => { + this.draining = true; + this.stopped = true; + const cutoff = Date.now() + DRAIN_TIMEOUT_MS; + while (this.getInFlightCount() > 0 && Date.now() < cutoff) { + await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS)); // Wait for DRAIN_POLL_MS for jobs to finish + } + + const remainingJobs = this.getInFlightCount(); + if (remainingJobs > 0) { + this.logger.warn({ + message: `Draining timed out with ${remainingJobs} in-flight. Locks will expire and jobs will be reclaimed`, + service: SERVICE_NAME, + method: "drain", + }); + } else { + this.logger.info({ + message: `${this.workerId} drained`, + service: SERVICE_NAME, + method: "drain", + }); + } + + await this.bufferService.shutdown(); // Flush buffers + }; + + // ************************ + // Observability + // ************************ + + getHealth = () => { + return { + workerId: this.workerId, + mode: this.queueMode, + dbConnected: this.isDbConnected(), + initComplete: this.initComplete, + draining: this.draining, + lastTickAt: this.lastTickAt, + inFlight: this.getInFlightCount(), + }; + }; + + countDueBacklog = () => { + return this.jobsRepository.countDueBacklog(Date.now()); + }; + + countAliveWorkers = async () => { + const workers = await this.queueWorkersRepository.findRecent(WORKER_STALE_MS); + return workers.filter((worker) => worker.processesJobs).length; + }; +} diff --git a/server/src/worker/worker.health-server.ts b/server/src/worker/worker.health-server.ts new file mode 100644 index 000000000..19bd22754 --- /dev/null +++ b/server/src/worker/worker.health-server.ts @@ -0,0 +1,129 @@ +import http from "http"; +import { IQueueWorker, WorkerHealth } from "@/worker/worker.interface.js"; +import { ILogger } from "@/utils/logger.js"; + +const SERVICE_NAME = "HealthServer"; +const DEFAULT_HEALTH_PORT = 52346; +const MAX_PORT_ATTEMPTS = 20; +const LIVENESS_STALE_MS = 30_000; // 30s ~6x POLL_MAX_MS so we don't restart workers +const CONTENT_TYPE_METRICS = "text/plain; version=0.0.4; charset=utf-8"; +const gauge = (name: string, help: string, value: number): string => `# HELP ${name} ${help}\n# TYPE ${name} gauge\n${name} ${value}\n`; + +export interface IHealthServer { + listen(): Promise; + close(): Promise; + address(): ReturnType; +} + +const isLive = (health: WorkerHealth): boolean => { + if (!health.initComplete) return false; // Not init yet + if (health.draining) return true; // Draining workers are still healthy + + if (health.lastTickAt === null) return false; // Never ticked + + if (Date.now() - health.lastTickAt >= LIVENESS_STALE_MS) return false; // Stale if last tick exceed LIVENESS_STALE_MS + + return true; +}; + +const isReady = (health: WorkerHealth): boolean => { + if (health.draining) return false; // Draining workers are not ready + return health.initComplete && health.dbConnected; +}; + +export class HealthServer implements IHealthServer { + private server: http.Server; + + constructor( + private logger: ILogger, + private port: string, + private worker: IQueueWorker + ) { + this.server = http.createServer(this.handle); + } + + private metrics = async (): Promise => { + const health = this.worker.getHealth(); + const [dueBacklog, aliveWorkers] = await Promise.all([this.worker.countDueBacklog(), this.worker.countAliveWorkers()]); + return ( + gauge("checkmate_worker_jobs_in_flight", "Jobs currently being processed by this worker", health.inFlight) + + gauge("checkmate_worker_due_backlog", "Due check jobs not currently locked (cluster-wide)", dueBacklog) + + gauge("checkmate_worker_alive_total", "Job-processing nodes seen within the TTL window (cluster-wide)", aliveWorkers) + + gauge("checkmate_worker_draining", "1 if this worker is draining, else 0", health.draining ? 1 : 0) + ); + }; + + private handle = async (req: http.IncomingMessage, res: http.ServerResponse) => { + const path = (req.url ?? "").split("?")[0]; // Strip query params + + // Metrics + if (path === "/metrics") { + try { + const body = await this.metrics(); + res.writeHead(200, { "Content-Type": CONTENT_TYPE_METRICS }); + res.end(body); + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: "metrics", + }); + res.writeHead(500).end(); + } + return; + } + + if (path !== "/livez" && path !== "/readyz") { + res.writeHead(404).end(); + return; + } + + // Get health + const health = this.worker.getHealth(); + const ok = path === "/livez" ? isLive(health) : isReady(health); + const code = ok ? 200 : 503; + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(health)); + }; + + listen = () => { + return new Promise((resolve, reject) => { + let port = Number(this.port) || DEFAULT_HEALTH_PORT; + const lastPort = port + MAX_PORT_ATTEMPTS; + + const onError = (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" && port < lastPort) { + this.logger.warn({ + message: `Health port ${port} in use, retrying on ${port + 1}`, + service: SERVICE_NAME, + method: "listen", + }); + port++; + this.server.listen(port); + return; + } + this.server.removeListener("error", onError); + reject(error); + }; + + this.server.on("error", onError); + this.server.listen(port, () => { + this.server.removeListener("error", onError); + this.logger.info({ + message: `Health server listening on ${port}`, + service: SERVICE_NAME, + method: "listen", + }); + resolve(); + }); + }); + }; + + address = () => this.server.address(); + + close = () => { + return new Promise((resolve) => { + this.server.close(() => resolve()); + }); + }; +} diff --git a/server/src/worker/worker.helper.ts b/server/src/worker/worker.helper.ts new file mode 100644 index 000000000..3d2d2e183 --- /dev/null +++ b/server/src/worker/worker.helper.ts @@ -0,0 +1,210 @@ +const SERVICE_NAME = "JobQueueHelper"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { ICheckService } from "@/domain/checks/check.service.js"; +import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; +import { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; +import { ILogger } from "@/utils/logger.js"; + +export interface IWorkerHelper { + readonly serviceName: string; + getCleanupOrphanedJob(): () => Promise; + getCleanupRetentionJob(): () => Promise; +} + +export interface MonitorActionDecision { + shouldCreateIncident: boolean; + shouldResolveIncident: boolean; + shouldSendNotification: boolean; + incidentReason: "status_down" | "threshold_breach" | null; + notificationReason: "status_change" | "threshold_breach" | null; + thresholdBreaches?: { + cpu?: boolean; + memory?: boolean; + disk?: boolean; + temp?: boolean; + }; +} + +export class WorkerHelper implements IWorkerHelper { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private checkService: ICheckService; + private settingsService: ISettingsService; + private monitorsRepository: IMonitorsRepository; + private jobsRepository: IJobsRepository; + private teamsRepository: ITeamsRepository; + private monitorStatsRepository: IMonitorStatsRepository; + private checksRepository: IChecksRepository; + private incidentsRepository: IIncidentsRepository; + private geoChecksRepository: IGeoChecksRepository; + + constructor( + logger: ILogger, + checkService: ICheckService, + settingsService: ISettingsService, + monitorsRepository: IMonitorsRepository, + jobsRepository: IJobsRepository, + teamsRepository: ITeamsRepository, + monitorStatsRepository: IMonitorStatsRepository, + checksRepository: IChecksRepository, + incidentsRepository: IIncidentsRepository, + geoChecksRepository: IGeoChecksRepository + ) { + this.logger = logger; + this.checkService = checkService; + this.settingsService = settingsService; + this.monitorsRepository = monitorsRepository; + this.jobsRepository = jobsRepository; + this.teamsRepository = teamsRepository; + this.monitorStatsRepository = monitorStatsRepository; + this.checksRepository = checksRepository; + this.incidentsRepository = incidentsRepository; + this.geoChecksRepository = geoChecksRepository; + } + + get serviceName() { + return WorkerHelper.SERVICE_NAME; + } + + getCleanupOrphanedJob = () => { + return async () => { + try { + this.logger.info({ + message: "Starting cleanup of orphaned data", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Get all valid team IDs + const validTeamIds = await this.teamsRepository.findAllTeamIds(); + this.logger.debug({ + message: `Found ${validTeamIds.length} valid teams`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Remove orphaned monitors (monitors without a valid team) + const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); + if (deletedMonitorCount > 0) { + this.logger.info({ + message: `Deleted ${deletedMonitorCount} orphaned monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned monitorStats (stats without a valid monitor) + const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); + this.logger.debug({ + message: `Found ${allMonitorIds.length} valid monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedStatsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedStatsCount} orphaned monitor stats`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned checks + const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedChecksCount} orphaned checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned incidents + const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedIncidentsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedIncidentsCount} orphaned incidents`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned geo checks + const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedGeoChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned jobs + const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedJobsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedJobsCount} orphaned jobs`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + this.logger.info({ + message: "Cleanup of orphaned data completed", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + + getCleanupRetentionJob = () => { + return async () => { + try { + const settings = await this.settingsService.getDBSettings(); + + const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS + + if (checkTTL === CHECK_TTL_SENTINEL) { + this.logger.info({ + message: `Check TTL is set to unlimited, skipping cleanup`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + return; + } + const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; + const cutoffDate = new Date(Date.now() - checkTTLInMs); + const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); + this.logger.info({ + message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + }; + }; +} diff --git a/server/src/worker/worker.interface.ts b/server/src/worker/worker.interface.ts new file mode 100644 index 000000000..98120fa47 --- /dev/null +++ b/server/src/worker/worker.interface.ts @@ -0,0 +1,90 @@ +import { Monitor } from "@/domain/monitors/monitor.types.js"; +import { Check } from "@/domain/checks/check.type.js"; +import { JobType } from "@/domain/jobs/job.type.js"; +import { MonitorPayloadMap, MonitorStatusResponse, StatusChangeResult } from "@/types/network.js"; +import { MonitorActionDecision } from "@/worker/worker.helper.js"; +import { QueueWorker } from "@/domain/queue-workers/queue-worker.type.js"; +import type { QueueMode } from "@/domain/app-settings/app-settings.type.js"; + +export type MonitorEvaluation = { + monitor: Monitor; + status: MonitorStatusResponse; // raw result from networkService.requestStatus + check: Check; + statusChange: StatusChangeResult; // from statusService.updateMonitorStatus + decision: MonitorActionDecision; // from MonitorStatusPolicy +}; + +export type WorkerJobFailure = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +}; + +export type WorkerMetrics = { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: WorkerJobFailure[]; + totalRuns: number; + totalFailures: number; + workers: QueueWorker[]; +}; + +export type WorkerJobSummary = { + monitorId: string | number; + monitorType: string | null; + monitorInterval: number | null; + monitorActive: boolean | null; + lockedBy: string | null; + lockedUntil: number | null; + nextScheduledAt: number; + runCount: number; + failCount: number; + failReason: string | null; + lastFinishedAt: number | null; +}; + +export type WorkerJobsPagination = { + page?: number; + rowsPerPage?: number; +}; + +export type WorkerJobsPage = { + jobs: WorkerJobSummary[]; + count: number; +}; + +export type WorkerHealth = { + workerId: string; + mode: QueueMode; + dbConnected: boolean; // mongoose connection readyState === 1 + initComplete: boolean; // init() finished, loops armed + draining: boolean; // SIGTERM received, no longer claiming + lastTickAt: number | null; // newest tick across all loops (epoch ms) + inFlight: number; // total in-flight jobs across types +}; +export interface IJobScheduler { + readonly serviceName: string; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; + wake(type: JobType): void; + getMetrics(): Promise; + getJobs(pagination: WorkerJobsPagination): Promise; + flushQueues(): Promise<{ success: boolean }>; + init(): Promise; + drain(): Promise; + shutdown(): Promise; +} + +export interface IQueueWorker extends IJobScheduler { + readonly serviceName: string; + getHealth(): WorkerHealth; + countDueBacklog(): Promise; + countAliveWorkers(): Promise; +} diff --git a/server/src/worker/worker.job-scheduler.ts b/server/src/worker/worker.job-scheduler.ts new file mode 100644 index 000000000..458db1d08 --- /dev/null +++ b/server/src/worker/worker.job-scheduler.ts @@ -0,0 +1,243 @@ +import { IJobScheduler } from "@/worker/worker.interface.js"; +import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.types.js"; +import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js"; +import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; +import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js"; +import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js"; +import { QueueMode } from "@/domain/app-settings/app-settings.type.js"; +import { ILogger } from "@/utils/logger.js"; + +const POLL_MS = 250; // base poll interval while a loop is actively claiming work +const SERVICE_NAME = "JobScheduler"; +const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window +const HEARTBEAT_MS = WORKER_STALE_MS / 3; // Worker can miss two beats without being considered stale +const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export class JobScheduler implements IJobScheduler { + static SERVICE_NAME = SERVICE_NAME; + constructor( + protected jobsRepository: IJobsRepository, + protected queueWorkersRepository: IQueueWorkersRepository, + protected monitorsRepository: IMonitorsRepository, + protected queueMode: QueueMode, + protected logger: ILogger, + protected readonly workerId: string // same id MongoJobsRepository stamps into lockedBy + ) {} + + get serviceName() { + return JobScheduler.SERVICE_NAME; + } + private heartbeatInFlight: Promise = Promise.resolve(); + + protected processesJobs = false; // scheduler-only by default; DBQueueWorker flips this on when it runs job loops + protected stopped = false; + protected draining = false; + protected ticking: Partial> = {}; // Whether a loop is mid tick + protected tickFns = new Map void>(); // Store tick fns so we can run them immediately on wake + protected timers = new Map(); + + // Per job type poll intervals. These back off on idle and reset on work or wake() + protected pollMs: Record = { + check: POLL_MS, + "geo-check": POLL_MS, + evaluate: POLL_MS, + "cleanup-orphaned": POLL_MS, + "cleanup-retention": POLL_MS, + }; + // Helpers, builds job seeds. immediate=true runs on the next tick; otherwise jitter spreads the herd. + protected toCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + id: jobId("check", monitor.id), + type: "check", + refId: monitor.id, + isActive: monitor.isActive, + nextScheduledAt: immediate ? now : now + Math.floor(Math.random() * monitor.interval), // Avoid herd + intervalMs: monitor.interval, + }); + + protected toGeoCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + ...this.toCheckJob(monitor, now, immediate), + id: jobId("geo-check", monitor.id), + type: "geo-check", + intervalMs: monitor.geoCheckInterval!, + }); + + protected toCleanupJob = (type: "cleanup-orphaned" | "cleanup-retention", now: number): JobSeed => ({ + id: jobId(type, null), + type, + refId: null, + isActive: true, + nextScheduledAt: now, + intervalMs: CLEANUP_INTERVAL_MS, + }); + + private toSummary = (job: Job): WorkerJobSummary => ({ + monitorId: job.refId ?? job.id, + monitorType: job.type, + monitorInterval: job.intervalMs, + monitorActive: job.isActive, + lockedBy: job.lockedBy, + lockedUntil: job.lockedUntil, + nextScheduledAt: job.nextScheduledAt, + runCount: job.runCount, + failCount: job.failCount, + failReason: job.lastFailReason, + lastFinishedAt: job.lastFinishedAt, + }); + wake = (type: JobType) => { + this.pollMs[type] = POLL_MS; + if (this.stopped || this.ticking[type]) return; // If it's not running or mid tick, do nothing + const tick = this.tickFns.get(type); + if (!tick) return; // Try to get a handle on the tick fn, if we can't we're done + const pending = this.timers.get(type); + if (pending) clearTimeout(pending); // Clear out scheduled tick if any + this.timers.set(type, setTimeout(tick, 0)); // Set immediate tick + }; + + addJob = async (_monitorId: string, monitor: Monitor) => { + const now = Date.now(); + await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now, true)); + this.wake("check"); + if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { + await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, now, true)); + this.wake("geo-check"); + } + }; + + deleteJob = async (monitor: Monitor) => { + await this.jobsRepository.deleteById(monitor.id); + }; + + pauseJob = async (monitor: Monitor) => { + await this.jobsRepository.setActiveById(monitor.id, false); + }; + + resumeJob = async (monitor: Monitor) => { + await this.jobsRepository.setActiveById(monitor.id, true); // a paused job's nextScheduledAt is now in the past, so it's due immediately + this.wake("check"); + if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) this.wake("geo-check"); + }; + + updateJob = async (monitor: Monitor) => { + await this.jobsRepository.updateScheduleById(monitor.id, "check", monitor.interval); + if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { + await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, Date.now(), true)); // a newly enabled geo check should run right away + this.wake("geo-check"); + } else { + await this.jobsRepository.deleteByIdAndType(monitor.id, "geo-check"); + } + }; + + getMetrics = async (): Promise => { + const rows = await this.jobsRepository.findAll(); + const recent = await this.queueWorkersRepository.findRecent(WORKER_STALE_MS); + const workers = recent.filter((worker) => worker.processesJobs); + const now = Date.now(); + return rows.reduce( + (acc, job) => { + acc.jobs++; + acc.totalRuns += job.runCount; + acc.totalFailures += job.failCount; + if (job.lockedBy !== null && job.lockedUntil !== null && job.lockedUntil > now) acc.activeJobs++; + if (job.failCount > 0) { + acc.failingJobs++; + acc.jobsWithFailures.push({ + monitorId: job.refId ?? job.id, + monitorUrl: null, + monitorType: job.type, + failedAt: job.lastFinishedAt, + failCount: job.failCount, + failReason: job.lastFailReason, + }); + } + return acc; + }, + { + jobs: 0, + activeJobs: 0, + failingJobs: 0, + jobsWithFailures: [], + totalRuns: 0, + totalFailures: 0, + workers, + } + ); + }; + + getJobs = async (pagination: WorkerJobsPagination) => { + const { jobs, count } = await this.jobsRepository.findPage(pagination); + return { jobs: jobs.map(this.toSummary), count }; + }; + + flushQueues = async () => { + await this.shutdown(); + return { success: true }; + }; + + // init is the scheduler's startup: register in the registry + (if primary) seed the queue. + // Subclasses override to ALSO start processing, calling super.init() first. + // Declared as a prototype method (not an arrow field) so subclasses can reach it via super.init(). + async init(): Promise { + this.stopped = false; + await this.heartbeat(); + this.timers.set("heartbeat", setInterval(this.heartbeat, HEARTBEAT_MS)); + if (this.queueMode === "primary") { + await this.reconcile(); + } + return true; + } + + // Seed queue + protected reconcile = async () => { + const now = Date.now(); + const monitors = (await this.monitorsRepository.findAll()) ?? []; + for (const monitor of monitors) { + await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now)); + if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { + await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, now)); + } + } + // Both cleanup jobs run immediately on every startup, then once a day + await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-orphaned", now)); + await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-retention", now)); + }; + + // Register worker + protected heartbeat = async () => { + try { + if (this.stopped) return; // Don't re-register if shutting down + this.heartbeatInFlight = this.queueWorkersRepository.upsert(this.workerId, this.queueMode, this.processesJobs).catch((error: unknown) => { + this.logger.warn({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: "heartbeat", + }); + }); + await this.heartbeatInFlight; + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : String(error), + service: SERVICE_NAME, + method: "heartbeat", + }); + } + }; + + drain = async () => { + this.stopped = true; + this.draining = true; + }; + + shutdown = async () => { + this.stopped = true; + // Stop all timers + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); // Clear the map out + await this.heartbeatInFlight; // Wait for in flight jobs + // Remove workers from registry + await this.queueWorkersRepository.deleteById(this.workerId); + }; +} diff --git a/server/src/worker/worker.monitor-status-policy.ts b/server/src/worker/worker.monitor-status-policy.ts new file mode 100644 index 000000000..4ef36b2a9 --- /dev/null +++ b/server/src/worker/worker.monitor-status-policy.ts @@ -0,0 +1,46 @@ +import { StatusChangeResult } from "@/types/network.js"; +import { MonitorActionDecision } from "@/worker/worker.helper.js"; + +export interface IMonitorStatusPolicy { + evaluate(statusChange: StatusChangeResult): MonitorActionDecision; +} + +export class MonitorStatusPolicy implements IMonitorStatusPolicy { + evaluate = (statusChangeResult: StatusChangeResult): MonitorActionDecision => { + const { monitor, statusChanged, prevStatus } = statusChangeResult; + + // Initialize result + const decision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }; + + if (!statusChanged) { + return decision; + } + + if (monitor.status === "down") { + // Monitor went down (unreachable) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (monitor.status === "breached") { + // Hardware monitor exceeded thresholds + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "threshold_breach"; + decision.notificationReason = "threshold_breach"; + } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { + // Monitor recovered from down or breached state + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + + return decision; + }; +} diff --git a/server/test/helpers/InMemoryIncidentsRepository.ts b/server/test/helpers/InMemoryIncidentsRepository.ts index f76236f89..74b79e0e3 100644 --- a/server/test/helpers/InMemoryIncidentsRepository.ts +++ b/server/test/helpers/InMemoryIncidentsRepository.ts @@ -1,5 +1,5 @@ -import type { IIncidentsRepository } from "../../src/repositories/incidents/IIncidentsRepository.ts"; -import type { Incident, IncidentSummary } from "../../src/types/index.ts"; +import type { IIncidentsRepository } from "../../src/domain/incidents/incident.repository.interface.ts"; +import type { Incident, IncidentSummary } from "../../src/domain/incidents/incident.type.ts"; import { randomUUID } from "crypto"; export class InMemoryIncidentsRepository implements IIncidentsRepository { diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index a151ee41e..45db5b3f7 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -1,5 +1,6 @@ -import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts"; -import type { Monitor, MonitorsSummary, CheckSnapshot } from "../../src/types/index.ts"; +import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/domain/monitors/monitor.repository.interface.ts"; +import type { CheckSnapshot } from "../../src/domain/checks/check.type.ts"; +import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.types.ts"; export class InMemoryMonitorsRepository implements IMonitorsRepository { private monitors: Monitor[] = []; @@ -31,10 +32,19 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m })); } + async findByTeamIdWithStats(_teamId: string, _config: TeamQueryConfig): Promise { + return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m })); + } + async findByIds(monitorIds: string[]): Promise { return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m })); } + async findByIdLean(monitorId: string): Promise { + const monitor = this.monitors.find((m) => m.id === monitorId); + return monitor ? { ...monitor } : null; + } + async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise { return this.findByIds(monitorIds); } diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 9b0592afc..5711f5aa9 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -1,14 +1,19 @@ import { jest } from "@jest/globals"; -import { QueueHelper } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; -import { StatusService } from "../../src/service/infrastructure/statusService.ts"; -import { IncidentService } from "../../src/service/business/incidentService.ts"; +import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts"; +import { CheckProducer } from "../../src/worker/worker.check-producer.ts"; +import { CheckEvaluator } from "../../src/worker/worker.check-evaluator.ts"; +import { NotificationReactor } from "../../src/worker/reactors/reactor.notification.ts"; +import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts"; +import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts"; +import { StatusService } from "../../src/service/statusService.ts"; +import { IncidentService } from "../../src/domain/incidents/incident.service.ts"; import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts"; import { createMockLogger } from "./createMockLogger.ts"; -import type { Monitor } from "../../src/types/monitor.ts"; +import type { Monitor } from "../../src/domain/monitors/monitor.types.ts"; import type { MonitorStatusResponse } from "../../src/types/network.ts"; -import type { Check } from "../../src/types/check.ts"; -import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts"; +import type { Check } from "../../src/domain/checks/check.type.ts"; +import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts"; let checkCounter = 0; @@ -64,20 +69,6 @@ const createStubMonitorStatsRepo = () => ({ deleteByMonitorIdsNotIn: jest.fn(), }); -const createStubChecksRepo = () => ({ - create: jest.fn(), - createChecks: jest.fn(), - findByMonitorId: jest.fn(), - findByTeamId: jest.fn(), - findLatestByMonitorIds: jest.fn(), - findByDateRangeAndMonitorId: jest.fn(), - findSummaryByTeamId: jest.fn(), - deleteByMonitorId: jest.fn(), - deleteByTeamId: jest.fn(), - deleteByMonitorIdsNotIn: jest.fn(), - deleteOlderThan: jest.fn(), -}); - export interface HeartbeatTestHarness { monitorsRepo: InMemoryMonitorsRepository; incidentsRepo: InMemoryIncidentsRepository; @@ -101,13 +92,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const logger = createMockLogger() as any; const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() }; - const statusService = new StatusService( - logger, - bufferStub as any, - monitorsRepo as any, - createStubMonitorStatsRepo() as any, - createStubChecksRepo() as any - ); + const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any); const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) }; const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any); @@ -126,7 +111,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { }), }; const checkService = { - buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => { + toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => { return makeCheck(response.status, response.code); }), }; @@ -143,24 +128,30 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; - const helper = new QueueHelper( - logger, - networkService as any, - statusService as any, - notificationsService as any, - checkService as any, - { getDBSettings: jest.fn() } as any, - bufferStub as any, - incidentService as any, - maintenanceWindowsRepo as any, + const checkProducer = new CheckProducer( monitorsRepo as any, - { findAllTeamIds: jest.fn() } as any, - createStubMonitorStatsRepo() as any, - createStubChecksRepo() as any, - incidentsRepo as any, - { buildGeoCheck: jest.fn() } as any, - { deleteByMonitorIdsNotIn: jest.fn() } as any + maintenanceWindowsRepo as any, + checkService as any, + networkService as any, + bufferStub as any, + logger ); + const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy()); + + const notificationReactor = new NotificationReactor(notificationsService as any); + const incidentReactor = new IncidentReactor(incidentService as any); + const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]); + + // Mirror the production check→evaluate flow: read the monitor fresh each cycle (so the + // accumulated statusWindow is visible), produce a check, then evaluate against that monitor. + const heartbeatJob = async (seedMonitor: Monitor) => { + const monitor = await monitorsRepo.findByIdLean(seedMonitor.id); + if (!monitor) return; + const result = await checkProducer.produce(monitor); + if (!result) return; // skipped (e.g. maintenance window) + const evaluation = await checkEvaluator.evaluate(result.status, result.check, monitor); + await reactorDispatcher.dispatch(evaluation); + }; return { monitorsRepo, @@ -172,7 +163,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { bufferStub, maintenanceWindowsRepo, messageBuilder, - heartbeatJob: helper.getHeartbeatJob(), + heartbeatJob, setNextResponse, setNextResponseFull, }; diff --git a/server/test/helpers/makeStatusPage.ts b/server/test/helpers/makeStatusPage.ts index 23c13bca1..56fd26596 100644 --- a/server/test/helpers/makeStatusPage.ts +++ b/server/test/helpers/makeStatusPage.ts @@ -1,4 +1,4 @@ -import type { StatusPage } from "../../src/types/index.ts"; +import type { StatusPage } from "../../src/domain/status-pages/status-page.type.ts"; export const makeStatusPage = (overrides?: Partial): StatusPage => ({ diff --git a/server/test/helpers/notificationMessage.ts b/server/test/helpers/notificationMessage.ts index c25644c81..f73ce67e7 100644 --- a/server/test/helpers/notificationMessage.ts +++ b/server/test/helpers/notificationMessage.ts @@ -1,5 +1,5 @@ -import type { Notification } from "../../src/types/index.ts"; -import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; +import type { Notification } from "../../src/domain/notifications/notification.type.ts"; +import type { NotificationMessage } from "../../src/domain/notifications/notification.type.ts"; export const makeNotification = (overrides?: Partial): Notification => ({ diff --git a/server/test/helpers/notificationProviderContract.ts b/server/test/helpers/notificationProviderContract.ts index f95048f24..3f3c5b80b 100644 --- a/server/test/helpers/notificationProviderContract.ts +++ b/server/test/helpers/notificationProviderContract.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@jest/globals"; -import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts"; -import type { Notification } from "../../src/types/index.ts"; -import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; +import type { INotificationProvider } from "../../src/domain/notifications/providers/INotificationProvider.ts"; +import type { Notification } from "../../src/domain/notifications/notification.type.ts"; +import type { NotificationMessage } from "../../src/domain/notifications/notification.type.ts"; const makeMessage = (): NotificationMessage => ({ type: "monitor_down", diff --git a/server/test/helpers/statusProviderContract.ts b/server/test/helpers/statusProviderContract.ts index 6f7c89466..af625f64d 100644 --- a/server/test/helpers/statusProviderContract.ts +++ b/server/test/helpers/statusProviderContract.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts"; -import type { Monitor } from "../../src/types/index.ts"; +import type { IStatusProvider } from "../../src/service/network/IStatusProvider.ts"; +import type { Monitor } from "../../src/domain/monitors/monitor.types.ts"; /** * Shared contract tests for all IStatusProvider implementations. diff --git a/server/test/integration/backfillLastEvaluatedAt.test.ts b/server/test/integration/backfillLastEvaluatedAt.test.ts new file mode 100644 index 000000000..f4c8a58c5 --- /dev/null +++ b/server/test/integration/backfillLastEvaluatedAt.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { MonitorModel } from "../../src/domain/monitors/monitor.model.ts"; +import { backfillMonitorLastEvaluatedAt } from "../../src/db/migration/0008_backfillMonitorLastEvaluatedAt.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +const logger = createMockLogger(); + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// Legacy monitors are inserted through the raw driver, NOT MonitorModel.create — the +// schema default (lastEvaluatedAt: 0) would otherwise stamp the field and the migration's +// { $exists: false } filter would skip it, defeating the test. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await MonitorModel.collection.deleteMany({}); +}); + +describe("0008_backfillMonitorLastEvaluatedAt", () => { + it("stamps the upgrade moment (not 0) on monitors missing lastEvaluatedAt", async () => { + await MonitorModel.collection.insertOne({ name: "legacy" }); // pre-upgrade doc, no lastEvaluatedAt + + const before = Date.now(); + await backfillMonitorLastEvaluatedAt(logger); + + const doc = await MonitorModel.collection.findOne({ name: "legacy" }); + // Must be a recent timestamp — backfilling 0 would replay the whole check history on the first evaluate. + expect(doc?.lastEvaluatedAt).not.toBe(0); + expect(doc?.lastEvaluatedAt).toBeGreaterThanOrEqual(before); + expect(doc?.lastEvaluatedAt).toBeLessThanOrEqual(Date.now()); + }); + + it("leaves monitors that already have lastEvaluatedAt untouched (idempotent)", async () => { + await MonitorModel.collection.insertOne({ name: "current", lastEvaluatedAt: 12345 }); + + await backfillMonitorLastEvaluatedAt(logger); + + const doc = await MonitorModel.collection.findOne({ name: "current" }); + expect(doc?.lastEvaluatedAt).toBe(12345); + }); + + it("drops the legacy jobs collection left behind by the old scheduler", async () => { + const db = mongoose.connection.db!; + await db.collection("jobs").insertOne({ id: "legacy-job", template: "monitor-job" }); // old-scheduler row + + await backfillMonitorLastEvaluatedAt(logger); + + const exists = await db.listCollections({ name: "jobs" }).toArray(); + expect(exists).toHaveLength(0); + }); + + it("does not throw when there is no jobs collection to drop", async () => { + await expect(backfillMonitorLastEvaluatedAt(logger)).resolves.toBeUndefined(); + }); +}); diff --git a/server/test/integration/heartbeatJobMaintenance.test.ts b/server/test/integration/heartbeatJobMaintenance.test.ts index 5df287242..3e51814db 100644 --- a/server/test/integration/heartbeatJobMaintenance.test.ts +++ b/server/test/integration/heartbeatJobMaintenance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; -import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts"; +import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts"; const makeMaintenanceWindow = (overrides?: Partial): MaintenanceWindow => { const now = new Date(); diff --git a/server/test/integration/incidentLifecycle.test.ts b/server/test/integration/incidentLifecycle.test.ts index 0fe1ddcf8..73b25d893 100644 --- a/server/test/integration/incidentLifecycle.test.ts +++ b/server/test/integration/incidentLifecycle.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { IncidentService } from "../../src/service/business/incidentService.ts"; +import { IncidentService } from "../../src/domain/incidents/incident.service.ts"; import { InMemoryIncidentsRepository } from "../helpers/InMemoryIncidentsRepository.ts"; import { createMockLogger } from "../helpers/createMockLogger.ts"; -import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts"; -import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts"; -import type { Monitor } from "../../src/types/monitor.ts"; -import type { MonitorActionDecision } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; +import type { IMonitorsRepository } from "../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IUsersRepository } from "../../src/domain/users/user.repository.interface.ts"; +import type { INotificationMessageBuilder } from "../../src/domain/notifications/notification.message-builder.ts"; +import type { Monitor } from "../../src/domain/monitors/monitor.types.ts"; +import type { MonitorActionDecision } from "../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/integration/jobsRepository.test.ts b/server/test/integration/jobsRepository.test.ts new file mode 100644 index 000000000..78ac17b64 --- /dev/null +++ b/server/test/integration/jobsRepository.test.ts @@ -0,0 +1,536 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoJobsRepository from "../../src/domain/jobs/job.repository.mongo.ts"; +import JobModel, { type JobDocument } from "../../src/domain/jobs/job.model.ts"; +import { BACKOFF_MS, LOCK_MS, type Job, type JobType } from "../../src/domain/jobs/job.type.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The headline guarantee of the queue — "exactly one claimer wins" — IS MongoDB's +// single-document atomicity on findOneAndUpdate, so it can only be exercised against a +// live engine. We boot a throwaway in-process mongod for this suite. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await JobModel.init(); // build the indexes the claim scan relies on +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await JobModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const NOW = 1_000_000; + +// Insert a job row directly, full control over every field. Defaults describe a due, +// unlocked, active check job. +const seedJob = (overrides: Partial = {}): Promise => + JobModel.create({ + _id: overrides._id ?? `check:${overrides.refId ?? "mon-1"}`, + type: "check" as JobType, + refId: "mon-1", + isActive: true, + nextScheduledAt: NOW, + intervalMs: 60_000, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + ...overrides, + }); + +const readRow = (id: string) => JobModel.findById(id).lean(); + +// record*/renewLocks are fenced on the repo's private workerId, so a pre-seeded "held" +// lock must be stamped with the same id the repo will check against. +const ownedBy = (r: MongoJobsRepository) => (r as unknown as { workerId: string }).workerId; + +const WORKER_ID = "worker-under-test"; + +describe("MongoJobsRepository", () => { + let repo: MongoJobsRepository; + + beforeEach(() => { + repo = new MongoJobsRepository(WORKER_ID); + }); + + // ── claimDue ─────────────────────────────────────────────────────────────── + describe("claimDue", () => { + it("claims a due, unlocked job and leases it", async () => { + await seedJob(); + + const claimed = await repo.claimDue("check", NOW); + + expect(claimed?.id).toBe("check:mon-1"); + const row = await readRow("check:mon-1"); + expect(row?.lockedBy).not.toBeNull(); + expect(row?.lockedUntil).toBe(NOW + LOCK_MS); + }); + + it("returns null when nothing is due yet", async () => { + await seedJob({ nextScheduledAt: NOW + 1 }); + expect(await repo.claimDue("check", NOW)).toBeNull(); + }); + + it("skips inactive (paused) jobs", async () => { + await seedJob({ isActive: false }); + expect(await repo.claimDue("check", NOW)).toBeNull(); + }); + + it("only claims the requested type", async () => { + await seedJob({ _id: "geo:mon-1", type: "geo-check" }); + expect(await repo.claimDue("check", NOW)).toBeNull(); + }); + + it("does not claim a job held by a live lease", async () => { + await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS }); + expect(await repo.claimDue("check", NOW)).toBeNull(); + }); + + it("reclaims a job whose lease has expired (crash recovery)", async () => { + await seedJob({ lockedBy: "dead-worker", lockedUntil: NOW - 1 }); + + const claimed = await repo.claimDue("check", NOW); + + expect(claimed?.id).toBe("check:mon-1"); + const row = await readRow("check:mon-1"); + expect(row?.lockedBy).not.toBe("dead-worker"); + expect(row?.lockedUntil).toBe(NOW + LOCK_MS); + }); + + it("returns the oldest-due job first", async () => { + await seedJob({ _id: "check:newer", refId: "newer", nextScheduledAt: NOW }); + await seedJob({ _id: "check:older", refId: "older", nextScheduledAt: NOW - 5_000 }); + + const claimed = await repo.claimDue("check", NOW); + expect(claimed?.id).toBe("check:older"); + }); + + it("lets exactly one of many concurrent claimers win a single due job", async () => { + await seedJob(); + + // Distinct repo instances = distinct workerIds = realistic competing workers. + const workers = Array.from({ length: 20 }, (_, i) => new MongoJobsRepository(`worker-${i}`)); + const results = await Promise.all(workers.map((w) => w.claimDue("check", NOW))); + + const winners = results.filter((r): r is Job => r !== null); + expect(winners).toHaveLength(1); + expect(winners[0].id).toBe("check:mon-1"); + }); + + it("hands two concurrent claimers two distinct due jobs (no double-claim)", async () => { + await seedJob({ _id: "check:a", refId: "a" }); + await seedJob({ _id: "check:b", refId: "b" }); + + const [w1, w2] = [new MongoJobsRepository("worker-1"), new MongoJobsRepository("worker-2")]; + const [r1, r2] = await Promise.all([w1.claimDue("check", NOW), w2.claimDue("check", NOW)]); + + expect(r1).not.toBeNull(); + expect(r2).not.toBeNull(); + expect(r1?.id).not.toBe(r2?.id); + expect([r1?.id, r2?.id].sort()).toEqual(["check:a", "check:b"]); + }); + }); + + // ── recordSuccess (fixed-rate reschedule) ──────────────────────────────────── + describe("recordSuccess", () => { + it("reschedules fixed-rate from the scheduled tick and releases the lease", async () => { + await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS, runCount: 4 }); + + const ok = await repo.recordSuccess("check:mon-1", NOW, 60_000, NOW + 1_500); + + expect(ok).toBe(true); + const row = await readRow("check:mon-1"); + // next tick measured from the scheduled time (NOW), not completion time + expect(row?.nextScheduledAt).toBe(NOW + 60_000); + expect(row?.lockedBy).toBeNull(); + expect(row?.lockedUntil).toBeNull(); + expect(row?.lastFinishedAt).toBe(NOW + 1_500); + expect(row?.runCount).toBe(5); + }); + + it("skips missed ticks when the worker has fallen behind, with jitter to avoid a herd", async () => { + await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS }); + + // scheduled NOW, interval 60s, but completion is 200s late → NOW+60s is already past + const now = NOW + 200_000; + await repo.recordSuccess("check:mon-1", NOW, 60_000, now); + + const row = await readRow("check:mon-1"); + // no catch-up burst: rebased to one interval from now, plus up to one interval of jitter + // so behind-jobs don't re-synchronize. Never earlier than now + interval (no over-checking). + expect(row?.nextScheduledAt).toBeGreaterThanOrEqual(now + 60_000); + expect(row?.nextScheduledAt).toBeLessThan(now + 120_000); + }); + + it("returns false for an unknown id", async () => { + expect(await repo.recordSuccess("missing", NOW, 60_000, NOW)).toBe(false); + }); + + it("does not release a lock owned by another worker (fenced no-op)", async () => { + // A worker whose lease expired and was reclaimed must not clobber the new owner's lock. + await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS, runCount: 7 }); + + const ok = await repo.recordSuccess("check:mon-1", NOW, 60_000, NOW + 1_000); + + expect(ok).toBe(false); + const row = await readRow("check:mon-1"); + expect(row?.lockedBy).toBe("other-worker"); // lock untouched + expect(row?.lockedUntil).toBe(NOW + LOCK_MS); + expect(row?.runCount).toBe(7); // not double-counted + }); + }); + + // ── renewLocks (lease renewal) ─────────────────────────────────────────────── + describe("renewLocks", () => { + it("extends the lease on a lock this worker holds", async () => { + await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS }); + + const renewed = await repo.renewLocks(["check:mon-1"], NOW + 1_000); + + expect(renewed).toBe(1); + expect((await readRow("check:mon-1"))?.lockedUntil).toBe(NOW + 1_000 + LOCK_MS); + }); + + it("does not renew a lock held by another worker (fenced)", async () => { + await seedJob({ lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS }); + + const renewed = await repo.renewLocks(["check:mon-1"], NOW + 1_000); + + expect(renewed).toBe(0); + expect((await readRow("check:mon-1"))?.lockedUntil).toBe(NOW + LOCK_MS); // unchanged + }); + + it("renews only the locks this worker holds out of a mixed batch", async () => { + await seedJob({ _id: "check:mine", refId: "mine", lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS }); + await seedJob({ _id: "check:theirs", refId: "theirs", lockedBy: "other-worker", lockedUntil: NOW + LOCK_MS }); + + const renewed = await repo.renewLocks(["check:mine", "check:theirs"], NOW + 1_000); + + expect(renewed).toBe(1); + expect((await readRow("check:mine"))?.lockedUntil).toBe(NOW + 1_000 + LOCK_MS); + expect((await readRow("check:theirs"))?.lockedUntil).toBe(NOW + LOCK_MS); + }); + + it("returns 0 for an empty id list without touching the db", async () => { + expect(await repo.renewLocks([], NOW)).toBe(0); + }); + + // Regression: a renewed lock keeps a slow-but-alive job from being reclaimed once its + // original lease would have expired — preventing the duplicate-execution path. + it("keeps a job from being reclaimed after the original lease window passes", async () => { + await seedJob(); + await repo.claimDue("check", NOW); // this worker now holds the lease until NOW + LOCK_MS + + // just before expiry, the still-running job renews its own lock + const renewAt = NOW + LOCK_MS - 1_000; + expect(await repo.renewLocks(["check:mon-1"], renewAt)).toBe(1); + + // a second worker scans PAST the original lease end — without renewal it would reclaim + const other = new MongoJobsRepository("competing-worker"); + expect(await other.claimDue("check", NOW + LOCK_MS + 1)).toBeNull(); + + const row = await readRow("check:mon-1"); + expect(row?.lockedBy).toBe(ownedBy(repo)); // still held by the original worker + expect(row?.lockedUntil).toBe(renewAt + LOCK_MS); + }); + }); + + // ── recordFailure (backoff) ────────────────────────────────────────────────── + describe("recordFailure", () => { + it("backs off, records the reason, bumps failCount, releases the lease", async () => { + await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS, failCount: 2 }); + + const ok = await repo.recordFailure("check:mon-1", new Error("connection refused"), NOW); + + expect(ok).toBe(true); + const row = await readRow("check:mon-1"); + expect(row?.nextScheduledAt).toBe(NOW + BACKOFF_MS); + expect(row?.lastFailReason).toBe("connection refused"); + expect(row?.lastFinishedAt).toBe(NOW); + expect(row?.failCount).toBe(3); + expect(row?.lockedBy).toBeNull(); + expect(row?.lockedUntil).toBeNull(); + }); + + it("stringifies a non-Error reason", async () => { + await seedJob({ lockedBy: ownedBy(repo), lockedUntil: NOW + LOCK_MS }); + await repo.recordFailure("check:mon-1", "weird non-error", NOW); + const row = await readRow("check:mon-1"); + expect(row?.lastFailReason).toBe("weird non-error"); + }); + + it("returns false for an unknown id", async () => { + expect(await repo.recordFailure("missing", new Error("x"), NOW)).toBe(false); + }); + }); + + // ── upsertEvaluate (handoff + $min coalescing) ─────────────────────────────── + describe("upsertEvaluate", () => { + it("creates a single evaluate row keyed by monitor", async () => { + await repo.upsertEvaluate("mon-1", NOW); + + const row = await readRow("evaluate:mon-1"); + expect(row?.type).toBe("evaluate"); + expect(row?.refId).toBe("mon-1"); + expect(row?.nextScheduledAt).toBe(NOW); + expect(row?.intervalMs).toBeNull(); + expect(row?.isActive).toBe(true); + }); + + it("coalesces rapid checks: a later call never pushes runAt out ($min)", async () => { + await repo.upsertEvaluate("mon-1", NOW); + await repo.upsertEvaluate("mon-1", NOW + 9_000); + + const rows = await JobModel.find({ type: "evaluate", refId: "mon-1" }).lean(); + expect(rows).toHaveLength(1); + expect(rows[0].nextScheduledAt).toBe(NOW); // earliest pending wins + }); + + it("lowers runAt when an earlier evaluation arrives ($min)", async () => { + await repo.upsertEvaluate("mon-1", NOW + 9_000); + await repo.upsertEvaluate("mon-1", NOW); + + const row = await readRow("evaluate:mon-1"); + expect(row?.nextScheduledAt).toBe(NOW); + }); + + it("concurrent upserts for one monitor produce exactly one row", async () => { + const workers = Array.from({ length: 20 }, (_, i) => new MongoJobsRepository(`worker-${i}`)); + await Promise.all(workers.map((w) => w.upsertEvaluate("mon-1", NOW))); + + const count = await JobModel.countDocuments({ type: "evaluate", refId: "mon-1" }); + expect(count).toBe(1); + }); + }); + + // ── monitor lifecycle CRUD ─────────────────────────────────────────────────── + describe("CRUD", () => { + it("upsertJob inserts, then refreshes config without clobbering scheduling state", async () => { + const job: Job = { + id: "check:mon-1", + type: "check", + refId: "mon-1", + isActive: true, + nextScheduledAt: NOW, + intervalMs: 60_000, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + }; + await repo.upsertJob(job); + + // simulate a running job that has advanced + holds a lease + await JobModel.updateOne({ _id: "check:mon-1" }, { $set: { nextScheduledAt: NOW + 5_000, lockedBy: "live", runCount: 7 } }); + + // a reconcile sweep re-upserts the same id with new config + await repo.upsertJob({ ...job, intervalMs: 30_000, isActive: false }); + + const row = await readRow("check:mon-1"); + expect(row?.intervalMs).toBe(30_000); // config refreshed ($set) + expect(row?.isActive).toBe(false); + expect(row?.nextScheduledAt).toBe(NOW + 5_000); // scheduling untouched ($setOnInsert) + expect(row?.lockedBy).toBe("live"); + expect(row?.runCount).toBe(7); + }); + + it("upsertCleanupJob resets nextScheduledAt on an existing row so it is due again on restart", async () => { + const job: Job = { + id: "cleanup-orphaned", + type: "cleanup-orphaned", + refId: null, + isActive: true, + nextScheduledAt: NOW, + intervalMs: 86_400_000, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + }; + await repo.upsertCleanupJob(job); + + // row has already advanced a day into the future after a previous run + await JobModel.updateOne({ _id: "cleanup-orphaned" }, { $set: { nextScheduledAt: NOW + 86_400_000, runCount: 3 } }); + + // a restart reconcile re-upserts and pulls it due now + await repo.upsertCleanupJob({ ...job, nextScheduledAt: NOW + 1_000 }); + + const row = await readRow("cleanup-orphaned"); + expect(row?.nextScheduledAt).toBe(NOW + 1_000); // schedule reset ($set) + expect(row?.runCount).toBe(3); // run history untouched ($setOnInsert) + }); + + it("setActiveById flips check and geo rows together", async () => { + await seedJob({ _id: "check:mon-1", type: "check" }); + await seedJob({ _id: "geo:mon-1", type: "geo-check" }); + + await repo.setActiveById("mon-1", false); + + const rows = await JobModel.find({ refId: "mon-1" }).lean(); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.isActive === false)).toBe(true); + }); + + it("updateScheduleById changes the interval on a single typed row", async () => { + await seedJob({ _id: "check:mon-1", type: "check", intervalMs: 60_000 }); + await seedJob({ _id: "geo:mon-1", type: "geo-check", intervalMs: 60_000 }); + + await repo.updateScheduleById("mon-1", "check", 15_000); + + expect((await readRow("check:mon-1"))?.intervalMs).toBe(15_000); + expect((await readRow("geo:mon-1"))?.intervalMs).toBe(60_000); // untouched + }); + + it("markMonitorsDue re-arms check and geo rows, capping the jitter for long intervals", async () => { + const armAt = NOW + 5_000_000; // well past the seeded nextScheduledAt so the jittered value is unambiguous + await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", intervalMs: 10_000, nextScheduledAt: NOW }); // shorter than the cap + await seedJob({ _id: "geo:mon-1", refId: "mon-1", type: "geo-check", intervalMs: 3_600_000, nextScheduledAt: NOW }); // far longer than the cap + + const modified = await repo.markMonitorsDue(["mon-1"], armAt); + + expect(modified).toBe(2); + const check = await readRow("check:mon-1"); + const geo = await readRow("geo:mon-1"); + // Short interval spreads over its own interval; long interval is capped so it still runs soon + expect(check!.nextScheduledAt).toBeGreaterThanOrEqual(armAt); + expect(check!.nextScheduledAt).toBeLessThan(armAt + 10_000); + expect(geo!.nextScheduledAt).toBeGreaterThanOrEqual(armAt); + expect(geo!.nextScheduledAt).toBeLessThan(armAt + 15_000); // REARM_JITTER_MS, not the hour-long interval + }); + + it("markMonitorsDue only touches the listed monitors and ignores evaluate/cleanup rows", async () => { + await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", nextScheduledAt: NOW }); + await seedJob({ _id: "check:mon-2", refId: "mon-2", type: "check", nextScheduledAt: NOW }); + await seedJob({ _id: "evaluate:mon-1", refId: "mon-1", type: "evaluate", nextScheduledAt: NOW }); + + const modified = await repo.markMonitorsDue(["mon-1"], NOW + 1_000); + + expect(modified).toBe(1); // only check:mon-1 + expect((await readRow("check:mon-2"))?.nextScheduledAt).toBe(NOW); // other monitor untouched + expect((await readRow("evaluate:mon-1"))?.nextScheduledAt).toBe(NOW); // evaluate row untouched + }); + + it("markMonitorsDue is a no-op for an empty monitor list", async () => { + await seedJob({ _id: "check:mon-1", refId: "mon-1", type: "check", nextScheduledAt: NOW }); + + const modified = await repo.markMonitorsDue([], NOW + 1_000); + + expect(modified).toBe(0); + expect((await readRow("check:mon-1"))?.nextScheduledAt).toBe(NOW); + }); + + it("deleteById drops every row for the monitor", async () => { + await seedJob({ _id: "check:mon-1", type: "check" }); + await seedJob({ _id: "geo:mon-1", type: "geo-check" }); + await repo.upsertEvaluate("mon-1", NOW); + + await repo.deleteById("mon-1"); + + expect(await JobModel.countDocuments({ refId: "mon-1" })).toBe(0); + }); + + it("deleteByMonitorIdsNotIn drops jobs for missing monitors but keeps valid and global rows", async () => { + await seedJob({ _id: "check:keep", refId: "keep", type: "check" }); + await seedJob({ _id: "geo:keep", refId: "keep", type: "geo-check" }); + await seedJob({ _id: "check:gone", refId: "gone", type: "check" }); + await seedJob({ _id: "cleanup-orphaned", refId: null, type: "cleanup-orphaned" }); + await seedJob({ _id: "cleanup-retention", refId: null, type: "cleanup-retention" }); + + const deleted = await repo.deleteByMonitorIdsNotIn(["keep"]); + + expect(deleted).toBe(1); // only check:gone + const remaining = (await JobModel.find().lean()).map((d) => d._id).sort(); + expect(remaining).toEqual(["check:keep", "cleanup-orphaned", "cleanup-retention", "geo:keep"]); + }); + + it("findById returns all of a monitor's rows", async () => { + await seedJob({ _id: "check:mon-1", type: "check" }); + await seedJob({ _id: "geo:mon-1", type: "geo-check" }); + + const jobs = await repo.findById("mon-1"); + expect(jobs.map((j) => j.id).sort()).toEqual(["check:mon-1", "geo:mon-1"]); + }); + }); + + // ── observability ──────────────────────────────────────────────────────────── + describe("observability", () => { + it("findPage returns a page plus the total count", async () => { + await seedJob({ _id: "check:a", refId: "a", nextScheduledAt: NOW + 1 }); + await seedJob({ _id: "check:b", refId: "b", nextScheduledAt: NOW + 2 }); + await seedJob({ _id: "check:c", refId: "c", nextScheduledAt: NOW + 3 }); + + const { jobs, count } = await repo.findPage({ page: 0, rowsPerPage: 2 }); + expect(count).toBe(3); + expect(jobs).toHaveLength(2); + expect(jobs.map((j) => j.id)).toEqual(["check:a", "check:b"]); // sorted by nextScheduledAt + }); + + it("findPage returns everything when rowsPerPage is 0", async () => { + await seedJob({ _id: "check:a", refId: "a" }); + await seedJob({ _id: "check:b", refId: "b" }); + + const { jobs, count } = await repo.findPage({}); + expect(count).toBe(2); + expect(jobs).toHaveLength(2); + }); + + it("findAll returns every row", async () => { + await seedJob({ _id: "check:a", refId: "a" }); + await seedJob({ _id: "geo:b", refId: "b", type: "geo-check" }); + + expect(await repo.findAll()).toHaveLength(2); + }); + }); + + // ── countDueBacklog (scaling signal) ───────────────────────────────────────── + describe("countDueBacklog", () => { + it("counts a due, active, unlocked check job", async () => { + await seedJob(); + expect(await repo.countDueBacklog(NOW)).toBe(1); + }); + + it("counts a check job whose lock has expired (reclaimable work)", async () => { + await seedJob({ lockedBy: "dead-worker", lockedUntil: NOW - 1 }); + expect(await repo.countDueBacklog(NOW)).toBe(1); + }); + + it("excludes not-yet-due, inactive, live-locked, and non-check rows", async () => { + await seedJob({ _id: "check:due", refId: "due" }); // counts + await seedJob({ _id: "check:future", refId: "future", nextScheduledAt: NOW + 1 }); // not due + await seedJob({ _id: "check:paused", refId: "paused", isActive: false }); // inactive + await seedJob({ _id: "check:locked", refId: "locked", lockedBy: "live", lockedUntil: NOW + LOCK_MS }); // live lock + await seedJob({ _id: "geo:mon", refId: "mon", type: "geo-check" }); // wrong type + await seedJob({ _id: "evaluate:mon", refId: "mon", type: "evaluate" }); // wrong type + + expect(await repo.countDueBacklog(NOW)).toBe(1); // only check:due + }); + + it("counts every qualifying check row across monitors", async () => { + await seedJob({ _id: "check:a", refId: "a" }); + await seedJob({ _id: "check:b", refId: "b" }); + await seedJob({ _id: "check:c", refId: "c", lockedBy: "live", lockedUntil: NOW + LOCK_MS }); // excluded + expect(await repo.countDueBacklog(NOW)).toBe(2); + }); + + it("returns 0 when nothing is due", async () => { + await seedJob({ nextScheduledAt: NOW + 10_000 }); + expect(await repo.countDueBacklog(NOW)).toBe(0); + }); + }); +}); diff --git a/server/test/integration/maintenanceWindowRepository.test.ts b/server/test/integration/maintenanceWindowRepository.test.ts new file mode 100644 index 000000000..f551ac0b0 --- /dev/null +++ b/server/test/integration/maintenanceWindowRepository.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoMaintenanceWindowsRepository from "../../src/domain/maintenance-windows/maintenance-window.repository.mongo.ts"; +import { MaintenanceWindowModel } from "../../src/domain/maintenance-windows/maintenance-window.model.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// Monitor membership edits are persisted by MongoDB's array update semantics. This +// regression suite exercises the repository against a live engine so adding/removing +// monitors cannot silently become a service-only mock assertion. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await MaintenanceWindowModel.init(); +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await MaintenanceWindowModel.deleteMany({}); +}); + +const makeId = () => new mongoose.Types.ObjectId(); + +describe("MongoMaintenanceWindowsRepository", () => { + describe("updateById", () => { + it("persists added and removed monitorIds when editing a maintenance window", async () => { + const repo = new MongoMaintenanceWindowsRepository(); + const teamId = makeId(); + const removedMonitorId = makeId(); + const keptMonitorId = makeId(); + const addedMonitorId = makeId(); + const window = await MaintenanceWindowModel.create({ + teamId, + monitorIds: [removedMonitorId, keptMonitorId], + active: true, + name: "Scheduled maintenance", + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: new Date("2026-04-10T02:00:00Z"), + end: new Date("2026-04-10T03:00:00Z"), + }); + + const updated = await repo.updateById(window._id.toString(), teamId.toString(), { + monitorIds: [keptMonitorId.toString(), addedMonitorId.toString()], + }); + + expect(updated.monitorIds).toEqual([keptMonitorId.toString(), addedMonitorId.toString()]); + expect(await repo.findByMonitorId(removedMonitorId.toString(), teamId.toString())).toEqual([]); + expect((await repo.findByMonitorId(keptMonitorId.toString(), teamId.toString())).map((item) => item.id)).toEqual([window._id.toString()]); + expect((await repo.findByMonitorId(addedMonitorId.toString(), teamId.toString())).map((item) => item.id)).toEqual([window._id.toString()]); + }); + }); +}); diff --git a/server/test/integration/schedulerReconcile.test.ts b/server/test/integration/schedulerReconcile.test.ts new file mode 100644 index 000000000..8b1a48f79 --- /dev/null +++ b/server/test/integration/schedulerReconcile.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { JobScheduler } from "../../src/worker/worker.job-scheduler.ts"; +import MongoJobsRepository from "../../src/domain/jobs/job.repository.mongo.ts"; +import MongoQueueWorkersRepository from "../../src/domain/queue-workers/queue-worker.repository.mongo.ts"; +import MongoMonitorsRepository from "../../src/domain/monitors/monitor.repository.mongo.ts"; +import JobModel, { type JobDocument } from "../../src/domain/jobs/job.model.ts"; +import { MonitorModel } from "../../src/domain/monitors/monitor.model.ts"; +import { QueueWorkerModel } from "../../src/domain/queue-workers/queue-worker.model.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The bug: a scheduler-only primary (queueMode=primary, queuePrimaryProcesses=false) +// never seeded the jobs collection, so a fresh start with existing monitors left the +// workers idle. The fix moved reconcile/heartbeat onto the base JobScheduler.init(). +// This proves init() against a live mongod actually writes the expected job rows. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await JobModel.init(); +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await JobModel.deleteMany({}); + await MonitorModel.collection.deleteMany({}); + await QueueWorkerModel.deleteMany({}); +}); + +const WORKER_ID = "scheduler-primary-1"; + +const seedMonitor = (overrides: Record = {}) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId: new mongoose.Types.ObjectId(), + name: "mon", + type: "http", + url: "https://example.com", + interval: 60_000, + isActive: true, + ...overrides, + }); + +const makeScheduler = () => + new JobScheduler( + new MongoJobsRepository(WORKER_ID), + new MongoQueueWorkersRepository(), + new MongoMonitorsRepository(), + "primary", + createMockLogger(), + WORKER_ID + ); + +const idsOf = async () => (await JobModel.find().lean()).map((j) => j._id); + +describe("JobScheduler.init (scheduler-only primary reconcile)", () => { + it("seeds a check row per monitor plus the two cleanup rows", async () => { + const m1 = await seedMonitor(); + const m2 = await seedMonitor({ name: "mon-2" }); + + const scheduler = makeScheduler(); + await scheduler.init(); + await scheduler.shutdown(); // stop the heartbeat interval + + const ids = await idsOf(); + expect(ids).toContain(`check:${m1.id}`); + expect(ids).toContain(`check:${m2.id}`); + expect(ids).toContain("cleanup-orphaned"); + expect(ids).toContain("cleanup-retention"); + }); + + it("seeds a geo-check row only for geo-enabled monitors", async () => { + const geo = await seedMonitor({ name: "geo", geoCheckEnabled: true }); + const plain = await seedMonitor({ name: "plain", geoCheckEnabled: false }); + + const scheduler = makeScheduler(); + await scheduler.init(); + await scheduler.shutdown(); + + const ids = await idsOf(); + expect(ids).toContain(`geo-check:${geo.id}`); + expect(ids).not.toContain(`geo-check:${plain.id}`); + }); + + it("registers the primary in the queue_workers registry", async () => { + await seedMonitor(); + + const scheduler = makeScheduler(); + await scheduler.init(); + await scheduler.shutdown(); + + // shutdown deregisters, so assert mid-flight: re-init then read before shutdown. + const scheduler2 = makeScheduler(); + await scheduler2.init(); + const row = await QueueWorkerModel.findById(WORKER_ID).lean(); + expect(row?.mode).toBe("primary"); + await scheduler2.shutdown(); + }); +}); diff --git a/server/test/unit/controllers/statusPageThemeFlag.test.ts b/server/test/unit/controllers/statusPageThemeFlag.test.ts index b4f9435a9..b1d51361a 100644 --- a/server/test/unit/controllers/statusPageThemeFlag.test.ts +++ b/server/test/unit/controllers/statusPageThemeFlag.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts"; -import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts"; +import { applyDefaultTheme, withoutThemeFields } from "../../../src/api/controllers/statusPageThemeFlag.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; import { makeStatusPage } from "../../helpers/makeStatusPage.ts"; describe("statusPageThemeFlag", () => { diff --git a/server/test/unit/middleware/handleErrors.test.ts b/server/test/unit/middleware/handleErrors.test.ts new file mode 100644 index 000000000..3ce49adbb --- /dev/null +++ b/server/test/unit/middleware/handleErrors.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { MulterError } from "multer"; +import { handleErrors } from "../../../src/api/middleware/handleErrors.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const logger = createMockLogger(); + +const makeRes = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response["status"]; + res.json = jest.fn().mockReturnValue(res) as unknown as Response["json"]; + return res; +}; + +describe("handleErrors", () => { + it("maps a multer file-size violation to 413 Payload Too Large", () => { + const res = makeRes(); + handleErrors(logger)(new MulterError("LIMIT_FILE_SIZE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(413); + }); + + it("maps other multer errors to 400", () => { + const res = makeRes(); + handleErrors(logger)(new MulterError("LIMIT_UNEXPECTED_FILE", "profileImage"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("preserves AppError status codes", () => { + const res = makeRes(); + handleErrors(logger)(new AppError({ status: 415, message: "bad image" }), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(415); + }); + + it("falls back to 500 for unknown errors", () => { + const res = makeRes(); + handleErrors(logger)(new Error("boom"), {} as Request, res, (() => {}) as NextFunction); + expect(res.status).toHaveBeenCalledWith(500); + }); +}); diff --git a/server/test/unit/middleware/statusPageCorsOrigin.test.ts b/server/test/unit/middleware/statusPageCorsOrigin.test.ts new file mode 100644 index 000000000..88a854845 --- /dev/null +++ b/server/test/unit/middleware/statusPageCorsOrigin.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createStatusPageCorsOrigin } from "../../../src/api/middleware/statusPageCorsOrigin.ts"; +import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; + +const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + isPublished: true, + customDomain: "status.example.com", + ...overrides, + }) as StatusPage; + +const createRepo = () => + ({ + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), + }) as unknown as jest.Mocked>; + +const invokeOrigin = (originFn: ReturnType, origin: string): Promise => + new Promise((resolve, reject) => { + originFn?.(origin, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result ?? false); + }); + }); + +describe("createStatusPageCorsOrigin", () => { + let repo: jest.Mocked>; + + beforeEach(() => { + repo = createRepo(); + }); + + it("allows the configured client host without a repository lookup", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://checkmate.example.com")).resolves.toBe(true); + expect(repo.findByCustomDomain).not.toHaveBeenCalled(); + }); + + it("caches published custom domain lookups", async () => { + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + }); + + it("rejects unpublished custom domains", async () => { + repo.findByCustomDomain.mockResolvedValue(makeStatusPage({ isPublished: false })); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + }); + + it("caches unknown custom domain lookups", async () => { + repo.findByCustomDomain.mockRejectedValue(new Error("Status page not found")); + const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository); + + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false); + + expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1); + expect(repo.findByCustomDomain).toHaveBeenCalledWith("unknown.example.com"); + }); +}); diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 000000000..ab0c20abd --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"]; + return res; +}; + +const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001"); + +const run = (req: Partial) => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + cspMiddleware(req as Request, res, next); + return { res, next }; +}; + +const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1]; + +describe("statusPageDocumentCsp", () => { + it("appends a tightened CSP on the public status page path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + expect(res.append).toHaveBeenCalledTimes(1); + const value = appendedValue(res); + expect(value).toContain("img-src 'self' data:"); + expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); + expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("does not restrict default-src or connect-src so the page can still reach the API", () => { + const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + const value = appendedValue(res); + expect(value).not.toContain("default-src"); + expect(value).not.toContain("connect-src"); + }); + + it("appends the CSP on a custom domain document at the host root", () => { + const { res, next } = run({ path: "/", hostname: "status.example.com" }); + + expect(res.append).toHaveBeenCalledTimes(1); + expect(appendedValue(res)).toContain("img-src 'self' data:"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves the app's own non-public routes untouched", () => { + const { res, next } = run({ path: "/status/create", hostname: "localhost" }); + + expect(res.append).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/test/unit/middleware/statusPagePublicApiPath.test.ts b/server/test/unit/middleware/statusPagePublicApiPath.test.ts new file mode 100644 index 000000000..95a799593 --- /dev/null +++ b/server/test/unit/middleware/statusPagePublicApiPath.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "@jest/globals"; +import { isPublicStatusPageApiPath } from "../../../src/api/middleware/statusPagePublicApiPath.ts"; + +describe("isPublicStatusPageApiPath", () => { + it("allows public resolve and slug routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("OPTIONS", "/api/v1/status-page/resolve")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page")).toBe(true); + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page?type=uptime")).toBe(true); + }); + + it("rejects authenticated and mutating status page routes", () => { + expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/team")).toBe(false); + expect(isPublicStatusPageApiPath("POST", "/api/v1/status-page")).toBe(false); + expect(isPublicStatusPageApiPath("PUT", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("DELETE", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false); + expect(isPublicStatusPageApiPath("GET", "/api/v1/monitors")).toBe(false); + }); +}); diff --git a/server/test/unit/middleware/upload.test.ts b/server/test/unit/middleware/upload.test.ts new file mode 100644 index 000000000..dc172d3da --- /dev/null +++ b/server/test/unit/middleware/upload.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "@jest/globals"; +import type { Request } from "express"; +import { imageUpload } from "../../../src/api/middleware/upload.ts"; +import { MAX_IMAGE_SIZE_BYTES } from "../../../src/types/upload.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +// Reaches into the multer instance config to exercise the fileFilter/limits in isolation. +// Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads). +const config = imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } }; + +const makeFile = (mimetype: string): Express.Multer.File => ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File; + +const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> => + new Promise((resolve) => { + config.fileFilter({} as Request, makeFile(mimetype), (error: unknown, accepted: unknown) => resolve({ error, accepted })); + }); + +describe("imageUpload middleware", () => { + it("enforces a file size limit so large uploads cannot exhaust memory", () => { + expect(config.limits.fileSize).toBe(MAX_IMAGE_SIZE_BYTES); + expect(config.limits.fileSize).toBeLessThanOrEqual(3 * 1024 * 1024); + }); + + it("limits a request to a single file", () => { + expect(config.limits.files).toBe(1); + }); + + it("accepts allowed image mime types", async () => { + for (const mimetype of ["image/jpeg", "image/png", "image/jpg"]) { + const { error, accepted } = await runFilter(mimetype); + expect(error).toBeNull(); + expect(accepted).toBe(true); + } + }); + + it("rejects non-image uploads with a 415 AppError", async () => { + const { error } = await runFilter("application/octet-stream"); + expect(error).toBeInstanceOf(AppError); + expect((error as AppError).status).toBe(415); + }); +}); diff --git a/server/test/unit/providers/network/advancedMatcher.test.ts b/server/test/unit/providers/network/advancedMatcher.test.ts index e0b2c773c..f94e9bb53 100644 --- a/server/test/unit/providers/network/advancedMatcher.test.ts +++ b/server/test/unit/providers/network/advancedMatcher.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { AdvancedMatcher } from "../../../../src/service/network/AdvancedMatcher.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts index a96731be5..1c31b3b1d 100644 --- a/server/test/unit/providers/network/dnsProvider.test.ts +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; -import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; +import { DNSProvider } from "../../../../src/service/network/DNSProvider.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 0f3dd68f6..f0b743f16 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts"; +import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/gameProvider.test.ts b/server/test/unit/providers/network/gameProvider.test.ts index d34e99621..d166c30f3 100644 --- a/server/test/unit/providers/network/gameProvider.test.ts +++ b/server/test/unit/providers/network/gameProvider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts"; +import { GameProvider } from "../../../../src/service/network/GameProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/grpcProvider.test.ts b/server/test/unit/providers/network/grpcProvider.test.ts index efa2e7992..bb1c78aac 100644 --- a/server/test/unit/providers/network/grpcProvider.test.ts +++ b/server/test/unit/providers/network/grpcProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts"; +import { GrpcProvider } from "../../../../src/service/network/GrpcProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/hardwareProvider.test.ts b/server/test/unit/providers/network/hardwareProvider.test.ts index 2f51454a0..2b4583af9 100644 --- a/server/test/unit/providers/network/hardwareProvider.test.ts +++ b/server/test/unit/providers/network/hardwareProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts"; +import { HardwareProvider } from "../../../../src/service/network/HardwareProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 2bcab27a3..7556f7595 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -38,7 +38,7 @@ jest.unstable_mockModule("got", () => ({ }, })); -const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts"); +const { HttpProvider } = await import("../../../../src/service/network/HttpProvider.ts"); const gotModule = await import("got"); const { HTTPError, RequestError } = gotModule; @@ -56,6 +56,7 @@ const makeMonitor = (overrides?: Partial): Monitor => useAdvancedMatching: false, matchMethod: undefined, expectedValue: undefined, + customUpCodes: [], ...overrides, }) as Monitor; @@ -253,14 +254,13 @@ describe("HttpProvider", () => { expect(result.extracted).toBe("value"); }); - it("sets status to false when response.ok is false even if matcher passes", async () => { + it("sets status to false when status code is non-2xx even if matcher passes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); const matcher = createMockMatcher({ ok: true, message: "Success" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor()); - // status = response.ok && matchResult.ok expect(result.status).toBe(false); }); }); @@ -345,4 +345,153 @@ describe("HttpProvider", () => { await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor"); }); }); + + // ── customUpCodes ──────────────────────────────────────────────────── + + describe("customUpCodes", () => { + it("returns status true when HTTPError status code is in customUpCodes", async () => { + const err = new HTTPError("Unauthorized"); + (err as any).response = { statusCode: 401 }; + (err as any).timings = { phases: { total: 30 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ customUpCodes: [401] })); + + expect(result.status).toBe(true); + expect(result.code).toBe(401); + }); + + it("returns status false when HTTPError status code is not in customUpCodes", async () => { + const err = new HTTPError("Internal Server Error"); + (err as any).response = { statusCode: 500 }; + (err as any).timings = { phases: { total: 40 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ customUpCodes: [401, 403] })); + + expect(result.status).toBe(false); + expect(result.code).toBe(500); + }); + + it("returns status false when customUpCodes is empty", async () => { + const err = new HTTPError("Unauthorized"); + (err as any).response = { statusCode: 401 }; + (err as any).timings = { phases: { total: 25 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ customUpCodes: [] })); + + expect(result.status).toBe(false); + expect(result.code).toBe(401); + }); + + it("treats non-2xx success response as up when status code is in customUpCodes", async () => { + mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301, statusMessage: "Moved Permanently" })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); + + expect(result.status).toBe(true); + expect(result.code).toBe(301); + }); + + it("does not override matcher failure even when status code is in customUpCodes", async () => { + mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); + const matcher = createMockMatcher({ ok: false, message: "Body mismatch" }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Body mismatch"); + }); + + it("does not override matcher failure on HTTPError when status code is in customUpCodes", async () => { + const err = new HTTPError("Unauthorized"); + (err as any).response = { + statusCode: 401, + body: '{"status":"down"}', + headers: { "content-type": "application/json" }, + }; + (err as any).timings = { phases: { total: 30 } }; + mockGot.mockRejectedValue(err); + const matcher = createMockMatcher({ + ok: false, + message: "Body mismatch", + extracted: "down", + }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor({ customUpCodes: [401], useAdvancedMatching: true })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Body mismatch"); + expect(result.payload).toEqual({ status: "down" }); + expect(result.extracted).toBe("down"); + }); + }); + + // ── HTTP method (GET / HEAD) ───────────────────────────────────────── + + describe("request method", () => { + it("passes GET to got when method is GET", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ method: "GET" })); + + expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" })); + }); + + it("passes HEAD to got when method is HEAD", async () => { + mockGot.mockResolvedValue(makeGotResponse({ body: "" })); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ method: "HEAD" })); + + expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" })); + }); + + it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => { + mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} })); + // A matcher that would fail — proving HEAD never reaches it + const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" }); + const { provider, advancedMatcher } = createProvider(matcher); + + const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" })); + + expect(result.status).toBe(true); + expect(advancedMatcher.validate).not.toHaveBeenCalled(); + }); + + it("bases HEAD status on the status code (down when not an up code)", async () => { + const err = new HTTPError("Internal Server Error"); + (err as any).response = { statusCode: 500, body: "", headers: {} }; + (err as any).timings = { phases: { total: 40 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ method: "HEAD" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(500); + }); + + it("respects customUpCodes for HEAD requests without consulting the matcher", async () => { + const err = new HTTPError("Unauthorized"); + (err as any).response = { statusCode: 401, body: "", headers: {} }; + (err as any).timings = { phases: { total: 30 } }; + mockGot.mockRejectedValue(err); + const { provider, advancedMatcher } = createProvider(); + + const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true })); + + expect(result.status).toBe(true); + expect(result.code).toBe(401); + expect(advancedMatcher.validate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/providers/network/pageSpeedProvider.test.ts b/server/test/unit/providers/network/pageSpeedProvider.test.ts index c461b0ad2..df5beb0be 100644 --- a/server/test/unit/providers/network/pageSpeedProvider.test.ts +++ b/server/test/unit/providers/network/pageSpeedProvider.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts"; +import { PageSpeedProvider } from "../../../../src/service/network/PageSpeedProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; -import type { ISettingsService } from "../../../../src/service/system/settingsService.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import type { HttpProvider } from "../../../../src/service/network/HttpProvider.ts"; +import type { ISettingsService } from "../../../../src/domain/app-settings/app-settings.service.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/pingProvider.test.ts b/server/test/unit/providers/network/pingProvider.test.ts index 2f38eef00..8f006a470 100644 --- a/server/test/unit/providers/network/pingProvider.test.ts +++ b/server/test/unit/providers/network/pingProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts"; +import { PingProvider } from "../../../../src/service/network/PingProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/portProvider.test.ts b/server/test/unit/providers/network/portProvider.test.ts index cbf64af16..8a7d55c4c 100644 --- a/server/test/unit/providers/network/portProvider.test.ts +++ b/server/test/unit/providers/network/portProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts"; +import { PortProvider } from "../../../../src/service/network/PortProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/utils.test.ts b/server/test/unit/providers/network/utils.test.ts index 56d8e7f9c..b37a5597c 100644 --- a/server/test/unit/providers/network/utils.test.ts +++ b/server/test/unit/providers/network/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import { timeRequest, NETWORK_ERROR, PING_ERROR, isStatusUp } from "../../../../src/service/network/utils.ts"; describe("network utils", () => { describe("timeRequest", () => { @@ -41,4 +41,40 @@ describe("network utils", () => { expect(PING_ERROR).toBe(5001); }); }); + + describe("isStatusUp", () => { + it("returns true for 2xx status codes", () => { + expect(isStatusUp(200)).toBe(true); + expect(isStatusUp(201)).toBe(true); + expect(isStatusUp(204)).toBe(true); + expect(isStatusUp(299)).toBe(true); + }); + + it("returns false for non-2xx status codes without customUpCodes", () => { + expect(isStatusUp(100)).toBe(false); + expect(isStatusUp(301)).toBe(false); + expect(isStatusUp(404)).toBe(false); + expect(isStatusUp(500)).toBe(false); + }); + + it("returns true when status code is in customUpCodes", () => { + expect(isStatusUp(301, [301])).toBe(true); + expect(isStatusUp(404, [200, 404])).toBe(true); + expect(isStatusUp(401, [401, 403])).toBe(true); + }); + + it("returns false when status code is not in customUpCodes", () => { + expect(isStatusUp(500, [401, 403])).toBe(false); + }); + + it("returns false when statusCode is undefined", () => { + expect(isStatusUp(undefined)).toBe(false); + expect(isStatusUp(undefined, [200])).toBe(false); + }); + + it("defaults customUpCodes to empty array", () => { + expect(isStatusUp(200)).toBe(true); + expect(isStatusUp(404)).toBe(false); + }); + }); }); diff --git a/server/test/unit/providers/network/webSocketProvider.test.ts b/server/test/unit/providers/network/webSocketProvider.test.ts index 73e4416a1..12ff32d63 100644 --- a/server/test/unit/providers/network/webSocketProvider.test.ts +++ b/server/test/unit/providers/network/webSocketProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; -import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts"; +import { WebSocketProvider } from "../../../../src/service/network/WebSocketProvider.ts"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; +import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/notifications/discordProvider.test.ts b/server/test/unit/providers/notifications/discordProvider.test.ts index 28c30ed22..bdb431fce 100644 --- a/server/test/unit/providers/notifications/discordProvider.test.ts +++ b/server/test/unit/providers/notifications/discordProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts"); +const { DiscordProvider } = await import("../../../../src/domain/notifications/providers/discord.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts index e0dc8b676..fecce8ef6 100644 --- a/server/test/unit/providers/notifications/emailProvider.test.ts +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts"; +import { EmailProvider } from "../../../../src/domain/notifications/providers/email.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts"; import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; -import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts"; +import type { IEmailService } from "../../../../src/service/emailService.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts index f6ad36692..65305e731 100644 --- a/server/test/unit/providers/notifications/matrixProvider.test.ts +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -7,7 +7,7 @@ const mockGotPut = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); -const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts"); +const { MatrixProvider } = await import("../../../../src/domain/notifications/providers/matrix.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/notificationUtils.test.ts b/server/test/unit/providers/notifications/notificationUtils.test.ts index 53a2666c8..5118e570e 100644 --- a/server/test/unit/providers/notifications/notificationUtils.test.ts +++ b/server/test/unit/providers/notifications/notificationUtils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { buildTestEmail, getTestMessage } from "../../../../src/service/infrastructure/notificationProviders/utils.ts"; -import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts"; +import { buildTestEmail, getTestMessage } from "../../../../src/domain/notifications/providers/utils.ts"; +import type { IEmailService } from "../../../../src/service/emailService.ts"; describe("notification utils", () => { describe("getTestMessage", () => { diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts index d335b5d01..ffd95a0d7 100644 --- a/server/test/unit/providers/notifications/ntfyProvider.test.ts +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts"); +const { NtfyProvider } = await import("../../../../src/domain/notifications/providers/ntfy.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/pagerdutyProvider.test.ts b/server/test/unit/providers/notifications/pagerdutyProvider.test.ts index f7c9c04c8..8b8d185b4 100644 --- a/server/test/unit/providers/notifications/pagerdutyProvider.test.ts +++ b/server/test/unit/providers/notifications/pagerdutyProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { PagerDutyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pagerduty.ts"); +const { PagerDutyProvider } = await import("../../../../src/domain/notifications/providers/pagerduty.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/pushoverProvider.test.ts b/server/test/unit/providers/notifications/pushoverProvider.test.ts index 8d32573b5..a227b0b1b 100644 --- a/server/test/unit/providers/notifications/pushoverProvider.test.ts +++ b/server/test/unit/providers/notifications/pushoverProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { PushoverProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pushover.ts"); +const { PushoverProvider } = await import("../../../../src/domain/notifications/providers/pushover.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/slackProvider.test.ts b/server/test/unit/providers/notifications/slackProvider.test.ts index 232eccaaa..20058ab11 100644 --- a/server/test/unit/providers/notifications/slackProvider.test.ts +++ b/server/test/unit/providers/notifications/slackProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost }, HTTPError: class extends Error {} })); -const { SlackProvider } = await import("../../../../src/service/infrastructure/notificationProviders/slack.ts"); +const { SlackProvider } = await import("../../../../src/domain/notifications/providers/slack.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/teamsProvider.test.ts b/server/test/unit/providers/notifications/teamsProvider.test.ts index d27d396a9..c3766ec16 100644 --- a/server/test/unit/providers/notifications/teamsProvider.test.ts +++ b/server/test/unit/providers/notifications/teamsProvider.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import type { Notification } from "../../../../src/types/notification.ts"; -import type { NotificationMessage } from "../../../../src/types/notificationMessage.ts"; +import type { Notification } from "../../../../src/domain/notifications/notification.type.ts"; +import type { NotificationMessage } from "../../../../src/domain/notifications/notification.type.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; const mockPost = jest.fn(); @@ -9,7 +9,7 @@ jest.unstable_mockModule("got", () => ({ HTTPError: class HTTPError extends Error {}, })); -const { TeamsProvider } = await import("../../../../src/service/infrastructure/notificationProviders/teams.ts"); +const { TeamsProvider } = await import("../../../../src/domain/notifications/providers/teams.ts"); const createNotification = (overrides?: Partial): Notification => ({ id: "notif-1", diff --git a/server/test/unit/providers/notifications/telegramProvider.test.ts b/server/test/unit/providers/notifications/telegramProvider.test.ts index fe9dd2c2e..f8f853e62 100644 --- a/server/test/unit/providers/notifications/telegramProvider.test.ts +++ b/server/test/unit/providers/notifications/telegramProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { TelegramProvider } = await import("../../../../src/service/infrastructure/notificationProviders/telegram.ts"); +const { TelegramProvider } = await import("../../../../src/domain/notifications/providers/telegram.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts index 88bfcdf7e..896a23395 100644 --- a/server/test/unit/providers/notifications/twilioProvider.test.ts +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts"); +const { TwilioProvider } = await import("../../../../src/domain/notifications/providers/twilio.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/providers/notifications/webhookProvider.test.ts b/server/test/unit/providers/notifications/webhookProvider.test.ts index caf17851b..fd16f2c06 100644 --- a/server/test/unit/providers/notifications/webhookProvider.test.ts +++ b/server/test/unit/providers/notifications/webhookProvider.test.ts @@ -6,7 +6,7 @@ import { testNotificationProviderContract } from "../../../helpers/notificationP const mockGotPost = jest.fn().mockResolvedValue({}); jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); -const { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts"); +const { WebhookProvider } = await import("../../../../src/domain/notifications/providers/webhook.ts"); const createProvider = () => { const logger = createMockLogger(); diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts index f1f395e2d..d994e5f24 100644 --- a/server/test/unit/services/bufferService.test.ts +++ b/server/test/unit/services/bufferService.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; -import { BufferService } from "../../../src/service/infrastructure/bufferService.ts"; +import { BufferService } from "../../../src/service/bufferService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { ICheckService } from "../../../src/service/business/checkService.ts"; -import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts"; -import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; -import type { Check, GeoCheck } from "../../../src/types/index.ts"; +import type { ICheckService } from "../../../src/domain/checks/check.service.ts"; +import type { IGeoChecksService } from "../../../src/domain/geo-checks/geo-check.service.ts"; +import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; +import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts"; +import type { Check } from "../../../src/domain/checks/check.type.ts"; +import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -23,6 +25,11 @@ const createMockSettingsService = (nodeEnv: string = "development") => getSettings: jest.fn().mockReturnValue({ nodeEnv }), }) as unknown as jest.Mocked; +const createMockJobsRepository = () => + ({ + upsertEvaluate: jest.fn().mockResolvedValue(true), + }) as unknown as jest.Mocked; + const makeCheck = (overrides?: Partial): Check => ({ id: "check-1", @@ -46,8 +53,9 @@ const createService = (nodeEnv: string = "development") => { const checkService = createMockCheckService(); const geoChecksService = createMockGeoChecksService(); const settingsService = createMockSettingsService(nodeEnv); - const service = new BufferService(logger as any, checkService, geoChecksService, settingsService); - return { service, logger, checkService, geoChecksService }; + const jobsRepository = createMockJobsRepository(); + const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository); + return { service, logger, checkService, geoChecksService, jobsRepository }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -343,6 +351,29 @@ describe("BufferService", () => { ); }); + it("arms the evaluate stage once per distinct monitor after the checks are stored", async () => { + const { service, jobsRepository } = createService(); + service.addToBuffer(makeCheck({ id: "c1", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } })); + service.addToBuffer(makeCheck({ id: "c2", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } })); + service.addToBuffer(makeCheck({ id: "c3", metadata: { monitorId: "mon-2", teamId: "team-1", type: "http" } })); + + await service.flushBuffer(); + + expect(jobsRepository.upsertEvaluate).toHaveBeenCalledTimes(2); + expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-1", expect.any(Number)); + expect(jobsRepository.upsertEvaluate).toHaveBeenCalledWith("mon-2", expect.any(Number)); + }); + + it("does not arm the evaluate stage when the check write fails", async () => { + const { service, checkService, jobsRepository } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed")); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(jobsRepository.upsertEvaluate).not.toHaveBeenCalled(); + }); + it("clears buffer even on error to prevent infinite retries", async () => { const { service, checkService, logger } = createService(); (checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed")); @@ -451,4 +482,32 @@ describe("BufferService", () => { ); }); }); + + // ── shutdown ───────────────────────────────────────────────────────────── + + describe("shutdown", () => { + it("stops the flush timer and flushes both buffers", async () => { + const { service, checkService, geoChecksService } = createService(); + service.addToBuffer(makeCheck()); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.shutdown(); + + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + + // Timer is cleared: advancing past the flush interval triggers no further flush. + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + }); + + it("is safe to call with empty buffers", async () => { + const { service, checkService, geoChecksService } = createService(); + + await service.shutdown(); + + expect(checkService.createChecks).not.toHaveBeenCalled(); + expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/services/checkEvaluator.test.ts b/server/test/unit/services/checkEvaluator.test.ts new file mode 100644 index 000000000..a22ceb1c7 --- /dev/null +++ b/server/test/unit/services/checkEvaluator.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { CheckEvaluator } from "../../../src/worker/worker.check-evaluator.ts"; +import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + ...overrides, + }) as Monitor; + +const createEvaluator = (overrides?: Record) => { + const defaults = { + statusService: { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: makeMonitor({ status: "up" }), statusChanged: false, prevStatus: "up", code: 200 }), + }, + monitorStatusPolicy: new MonitorStatusPolicy(), + ...overrides, + }; + const evaluator = new CheckEvaluator(defaults.statusService as any, defaults.monitorStatusPolicy as any); + return { evaluator, defaults }; +}; + +describe("CheckEvaluator", () => { + it("threads status, check, and monitor through to the status service", async () => { + const { evaluator, defaults } = createEvaluator(); + const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any; + const check = { id: "check-1" } as any; + const monitor = makeMonitor({ status: "up" }); + + await evaluator.evaluate(status, check, monitor); + + expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalledWith(status, check, monitor); + }); + + it("returns an evaluation that opens an incident on a down transition", async () => { + const status = { monitorId: "m1", status: false, code: 500, message: "Error" } as any; + const check = { id: "check-1" } as any; + const statusChange = { monitor: makeMonitor({ status: "down" }), statusChanged: true, prevStatus: "up", code: 500 }; + const { evaluator } = createEvaluator({ + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) }, + }); + + const result = await evaluator.evaluate(status, check, makeMonitor()); + + expect(result).toMatchObject({ + monitor: statusChange.monitor, + status, + check, + statusChange, + decision: expect.objectContaining({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "status_down", + }), + }); + }); + + it("returns an evaluation that resolves an incident on recovery", async () => { + const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any; + const statusChange = { monitor: makeMonitor({ status: "up" }), statusChanged: true, prevStatus: "down", code: 200 }; + const { evaluator } = createEvaluator({ + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) }, + }); + + const result = await evaluator.evaluate(status, { id: "check-1" } as any, makeMonitor({ status: "down" })); + + expect(result.decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true }); + }); + + it("produces a no-op decision when status did not change", async () => { + const status = { monitorId: "m1", status: true, code: 200, message: "OK" } as any; + const { evaluator } = createEvaluator(); + + const result = await evaluator.evaluate(status, { id: "check-1" } as any, makeMonitor()); + + expect(result.decision).toMatchObject({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + }); + }); +}); diff --git a/server/test/unit/services/checkPipeline.test.ts b/server/test/unit/services/checkPipeline.test.ts new file mode 100644 index 000000000..008e879fa --- /dev/null +++ b/server/test/unit/services/checkPipeline.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + geoCheckEnabled: false, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +const activeWindow = () => { + const now = Date.now(); + return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 }; +}; + +// ── GeoChecksPipeline ────────────────────────────────────────────────────────── + +const createGeoPipeline = (overrides?: Record) => { + const defaults = { + logger: createMockLogger(), + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(null) }, + buffer: { addGeoCheckToBuffer: jest.fn() }, + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, + ...overrides, + }; + const pipeline = new GeoChecksPipeline( + defaults.maintenanceWindowsRepository as any, + defaults.geoChecksService as any, + defaults.buffer as any, + defaults.logger as any + ); + return { pipeline, defaults }; +}; + +const geoMonitor = (overrides?: Partial) => + makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"], ...overrides }); + +describe("GeoChecksPipeline", () => { + it("throws when monitor id is missing", async () => { + const { pipeline } = createGeoPipeline(); + await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + it("returns null when geoCheckEnabled is false", async () => { + const { pipeline, defaults } = createGeoPipeline(); + const result = await pipeline.run(makeMonitor({ geoCheckEnabled: false })); + expect(result).toBeNull(); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns null when monitor type does not support geo checks", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ type: "hardware" })); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when geoCheckLocations is empty", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ geoCheckLocations: [] })); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("warns when geoCheckLocations is undefined", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor({ geoCheckLocations: undefined as any })); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("skips when in maintenance window", async () => { + const { pipeline, defaults } = createGeoPipeline({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + await pipeline.run(geoMonitor()); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when buildGeoCheck returns null", async () => { + const { pipeline, defaults } = createGeoPipeline(); + await pipeline.run(geoMonitor()); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); + }); + + it("adds geo check to buffer on success", async () => { + const geoCheck = { id: "gc-1", monitorId: "m1" }; + const { pipeline, defaults } = createGeoPipeline({ + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, + }); + await pipeline.run(geoMonitor()); + expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); + }); + + it("propagates errors (the job glue handles isolation)", async () => { + const { pipeline } = createGeoPipeline({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, + }); + await expect(pipeline.run(geoMonitor())).rejects.toThrow("api timeout"); + }); +}); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts new file mode 100644 index 000000000..4edd12bb5 --- /dev/null +++ b/server/test/unit/services/checkProducer.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { CheckProducer } from "../../../src/worker/worker.check-producer.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + ...overrides, + }) as Monitor; + +// A maintenance window straddling "now" — active per isWindowActive. +const activeWindow = () => { + const now = Date.now(); + return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 }; +}; + +const createProducer = (overrides?: Record) => { + const defaults = { + logger: createMockLogger(), + monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) }, + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, + checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, + networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + buffer: { addToBuffer: jest.fn() }, + ...overrides, + }; + const producer = new CheckProducer( + defaults.monitorsRepository as any, + defaults.maintenanceWindowsRepository as any, + defaults.checkService as any, + defaults.networkService as any, + defaults.buffer as any, + defaults.logger as any + ); + return { producer, defaults }; +}; + +describe("CheckProducer", () => { + it("throws when monitor id is missing", async () => { + const { producer } = createProducer(); + await expect(producer.produce({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + // ── maintenance gate ────────────────────────────────────────────────────── + + it("skips the check and flips status to 'maintenance' when in a maintenance window", async () => { + const { producer, defaults } = createProducer({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + const result = await producer.produce(makeMonitor({ status: "up" })); + + expect(result).toBeNull(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] }); + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.buffer.addToBuffer).not.toHaveBeenCalled(); + }); + + it("does not re-write status when already in maintenance", async () => { + const { producer, defaults } = createProducer({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) }, + }); + + const result = await producer.produce(makeMonitor({ status: "maintenance" })); + + expect(result).toBeNull(); + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("proceeds with the check when the maintenance window is inactive", async () => { + const { producer, defaults } = createProducer({ + maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) }, + }); + + await producer.produce(makeMonitor()); + + expect(defaults.networkService.requestStatus).toHaveBeenCalled(); + }); + + // ── acquire / record ────────────────────────────────────────────────────── + + it("throws when the network response is null", async () => { + const { producer } = createProducer({ + networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, + }); + + await expect(producer.produce(makeMonitor())).rejects.toThrow("No network response"); + }); + + it("returns null, warns, and does not buffer when toCheck yields nothing", async () => { + const { producer, defaults } = createProducer({ + checkService: { toCheck: jest.fn().mockReturnValue(null) }, + }); + + const result = await producer.produce(makeMonitor()); + + expect(result).toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); + expect(defaults.buffer.addToBuffer).not.toHaveBeenCalled(); + }); + + it("buffers the built check and returns the status and check on success", async () => { + const status = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const check = { id: "check-1" }; + const { producer, defaults } = createProducer({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + checkService: { toCheck: jest.fn().mockReturnValue(check) }, + }); + + const result = await producer.produce(makeMonitor()); + + expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check); + expect(result).toEqual({ status, check }); + }); +}); diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts index 93919df5f..104ccbaa2 100644 --- a/server/test/unit/services/checkService.test.ts +++ b/server/test/unit/services/checkService.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { CheckService } from "../../../src/service/business/checkService.ts"; +import { CheckService } from "../../../src/domain/checks/check.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts"; -import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts"; +import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/network.ts"; +import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -52,12 +55,12 @@ describe("CheckService", () => { }); }); - // ── buildCheck ─────────────────────────────────────────────────────────── + // ── toCheck ────────────────────────────────────────────────────────────── - describe("buildCheck", () => { + describe("toCheck", () => { it("builds a basic HTTP check with correct fields", () => { const { service } = createService(); - const check = service.buildCheck(makeStatusResponse()); + const check = service.toCheck(makeStatusResponse()); expect(check).toBeDefined(); expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" }); @@ -71,14 +74,14 @@ describe("CheckService", () => { it("defaults responseTime to 0 when falsy", () => { const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ responseTime: undefined })); + const check = service.toCheck(makeStatusResponse({ responseTime: undefined })); expect(check!.responseTime).toBe(0); }); it("includes timings when provided", () => { const timings = { start: 0, socket: 10, lookup: 20 }; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ timings } as any)); + const check = service.toCheck(makeStatusResponse({ timings } as any)); expect(check!.timings).toBe(timings); }); @@ -105,7 +108,7 @@ describe("CheckService", () => { } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); expect(check!.accessibility).toBe(90); expect(check!.bestPractices).toBe(80); @@ -118,7 +121,7 @@ describe("CheckService", () => { it("returns undefined when pagespeed payload is missing", () => { const { service, logger } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any)); expect(check).toBeUndefined(); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" })); @@ -126,7 +129,7 @@ describe("CheckService", () => { it("defaults categories and audits to empty when lighthouseResult is missing", () => { const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any)); expect(check!.accessibility).toBe(0); expect(check!.bestPractices).toBe(0); @@ -145,7 +148,7 @@ describe("CheckService", () => { }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); expect(check!.audits!.cls!.score).toBe("informative"); }); @@ -160,7 +163,7 @@ describe("CheckService", () => { }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); expect(check!.audits!.cls!.score).toBeNull(); }); @@ -175,7 +178,7 @@ describe("CheckService", () => { }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); expect(check!.audits!.si!.numericValue).toBeUndefined(); }); @@ -191,7 +194,7 @@ describe("CheckService", () => { }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); expect(check!.audits!.cls).toBeUndefined(); expect(check!.audits!.si).toBeUndefined(); @@ -213,7 +216,7 @@ describe("CheckService", () => { capture: { screenshot: "base64data" }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any)); expect(check!.cpu).toEqual({ usage_percent: 0.5 }); expect(check!.memory).toEqual({ usage_percent: 0.6 }); @@ -229,7 +232,7 @@ describe("CheckService", () => { errors: [{ message: "timeout" }], } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any)); expect(check!.errors).toEqual([{ message: "timeout" }]); }); @@ -240,14 +243,14 @@ describe("CheckService", () => { errors: { errors: [{ message: "nested error" }] }, } as any; const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + const check = service.toCheck(makeStatusResponse({ type: "hardware", payload } as any)); expect(check!.errors).toEqual([{ message: "nested error" }]); }); it("handles undefined payload gracefully", () => { const { service } = createService(); - const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any)); + const check = service.toCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any)); expect(check).toBeDefined(); expect(check!.cpu).toBeUndefined(); @@ -258,6 +261,84 @@ describe("CheckService", () => { }); }); + // ── toStatusResponse round-trip (Phase 2 §5 guard) ───────────────────────── + // The DB-queue split persists a Check, then the evaluator rebuilds the status response from it. + // toStatusResponse is only correct because every field a downstream consumer reads survives + // toCheck → toStatusResponse. These tests pin that: if a future change drops a consumed field + // from the round-trip, they fail instead of silently regressing the evaluator/reactors. + + describe("toStatusResponse round-trip", () => { + const makeHardwareStatus = (overrides?: Partial): MonitorStatusResponse => + makeStatusResponse({ + type: "hardware", + status: true, + code: 200, + message: "OK", + responseTime: 42, + payload: { + data: { + cpu: { usage_percent: 0.92 }, + memory: { usage_percent: 0.81 }, + disk: [{ device: "/dev/sda", usage_percent: 0.75 }], + host: { os: "linux" }, + net: [{ name: "eth0" }], + }, + } as HardwareStatusPayload, + ...overrides, + } as any); + + it("uptime: preserves every consumed scalar field", () => { + const { service } = createService(); + const status = makeStatusResponse({ type: "http", status: false, code: 503, message: "Service Unavailable", responseTime: 87 }); + + const rebuilt = service.toStatusResponse(service.toCheck(status)!); + + expect(rebuilt.monitorId).toBe(status.monitorId); + expect(rebuilt.teamId).toBe(status.teamId); + expect(rebuilt.type).toBe(status.type); + expect(rebuilt.status).toBe(status.status); + expect(rebuilt.code).toBe(status.code); + expect(rebuilt.message).toBe(status.message); + expect(rebuilt.responseTime).toBe(status.responseTime); + }); + + it("hardware: preserves payload.data metrics verbatim, same units (usage_percent stays a 0–1 decimal)", () => { + const { service } = createService(); + const status = makeHardwareStatus(); + + const rebuilt = service.toStatusResponse(service.toCheck(status)!); + + const original = status.payload as HardwareStatusPayload; + const roundtripped = rebuilt.payload as HardwareStatusPayload; + expect(roundtripped.data).toEqual(original.data); + expect(roundtripped.data.cpu!.usage_percent).toBe(0.92); // not rescaled to 92 + }); + + it("hardware: a real consumer (extractThresholdBreaches) behaves identically on raw vs round-tripped status", () => { + const { service } = createService(); + const builder = new NotificationMessageBuilder(); + const monitor = { + id: "mon-1", + type: "hardware", + cpuAlertThreshold: 90, // 0.92 → 92% breaches + memoryAlertThreshold: 90, // 0.81 → 81% does not + diskAlertThreshold: 90, // 0.75 → 75% does not + tempAlertThreshold: 100, + } as Monitor; + const status = makeHardwareStatus(); + + const rebuilt = service.toStatusResponse(service.toCheck(status)!); + + const fromRaw = builder.extractThresholdBreaches(monitor, status as MonitorStatusResponse); + const fromRebuilt = builder.extractThresholdBreaches(monitor, rebuilt as MonitorStatusResponse); + + // sanity: the fixture actually triggers a breach, so the equality below is not vacuous + expect(fromRaw).toHaveLength(1); + expect(fromRaw[0].metric).toBe("cpu"); + expect(fromRebuilt).toEqual(fromRaw); + }); + }); + // ── createChecks ───────────────────────────────────────────────────────── describe("createChecks", () => { diff --git a/server/test/unit/services/dbQueueWorker.test.ts b/server/test/unit/services/dbQueueWorker.test.ts new file mode 100644 index 000000000..cabf90e31 --- /dev/null +++ b/server/test/unit/services/dbQueueWorker.test.ts @@ -0,0 +1,874 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { DBQueueWorker } from "../../../src/worker/worker.db-queue.ts"; +import { LOCK_MS } from "../../../src/domain/jobs/job.type.ts"; +import type { Job } from "../../../src/domain/jobs/job.type.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { QueueMode } from "../../../src/domain/app-settings/app-settings.type.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +// ── Notes ────────────────────────────────────────────────────────────────────── +// +// DBQueueWorker is the orchestrator: it drives the polling loops, claims batches +// sized to free capacity, runs jobs (check / evaluate / geo-check / cleanup) and +// renews their locks, and registers itself in the worker heartbeat. The repository +// claim/lock atomicity is covered in integration/jobsRepository.test.ts; here we +// test only the orchestration around it, with the repository fully mocked. +// +// Fake timers are mandatory: init() starts self-re-arming setTimeout loops and a +// heartbeat setInterval, so without them the loops would leak across tests. The +// loop re-arms at POLL_MS (250ms) and the lock renews at LOCK_MS / 3 (20s). + +const POLL_MS = 250; +const LOCK_RENEW_MS = LOCK_MS / 3; +const DRAIN_TIMEOUT_MS = 25_000; // mirror of DBQueueWorker DRAIN_TIMEOUT_MS +const DRAIN_POLL_MS = 200; // mirror of DBQueueWorker DRAIN_POLL_MS + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + isActive: true, + geoCheckEnabled: false, + geoCheckInterval: 300000, + lastEvaluatedAt: 0, + ...overrides, + }) as Monitor; + +const makeJob = (overrides?: Partial): Job => ({ + id: "check:m1", + type: "check", + refId: "m1", + isActive: true, + nextScheduledAt: 1000, + intervalMs: 60000, + lockedBy: null, + lockedUntil: null, + runCount: 0, + failCount: 0, + lastFinishedAt: null, + lastFailReason: null, + ...overrides, +}); + +const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses?: boolean; mocks?: Record }) => { + const jobsRepository = { + claimDueBatch: jest.fn().mockResolvedValue([]), + upsertJob: jest.fn().mockResolvedValue(true), + upsertCleanupJob: jest.fn().mockResolvedValue(true), + upsertEvaluate: jest.fn().mockResolvedValue(true), + recordSuccess: jest.fn().mockResolvedValue(true), + recordFailure: jest.fn().mockResolvedValue(true), + recordOneShot: jest.fn().mockResolvedValue(true), + renewLocks: jest.fn().mockResolvedValue(1), + deleteById: jest.fn().mockResolvedValue(true), + deleteByIdAndType: jest.fn().mockResolvedValue(true), + setActiveById: jest.fn().mockResolvedValue(true), + updateScheduleById: jest.fn().mockResolvedValue(true), + findAll: jest.fn().mockResolvedValue([]), + findPage: jest.fn().mockResolvedValue({ jobs: [], count: 0 }), + countDueBacklog: jest.fn().mockResolvedValue(0), + }; + const monitorsRepository = { + findByIds: jest.fn().mockResolvedValue([makeMonitor()]), + findByIdLean: jest.fn().mockResolvedValue(makeMonitor()), + findAll: jest.fn().mockResolvedValue([]), + updateById: jest.fn().mockResolvedValue({}), + }; + const checksRepository = { + findUnevaluatedByMonitorId: jest.fn().mockResolvedValue([]), + }; + const checkService = { + toStatusResponse: jest.fn().mockReturnValue({ status: "up" }), + toLastEvaluatedAt: jest.fn().mockReturnValue(12345), + }; + const bufferService = { + addToBuffer: jest.fn(), + addGeoCheckToBuffer: jest.fn(), + scheduleNextFlush: jest.fn(), + flushBuffer: jest.fn().mockResolvedValue(undefined), + flushGeoBuffer: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + }; + const checkProducer = { + produce: jest.fn().mockResolvedValue({ status: { status: "up" }, check: { id: "c1" } }), + }; + const checkEvaluator = { + evaluate: jest.fn().mockResolvedValue({ monitor: makeMonitor(), statusChange: { monitor: makeMonitor() }, decision: {} }), + }; + const geoCheckPipeline = { run: jest.fn().mockResolvedValue(null) }; + const dispatcher = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const helper = { + getCleanupOrphanedJob: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(undefined)), + getCleanupRetentionJob: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(undefined)), + }; + const queueWorkersRepository = { + upsert: jest.fn().mockResolvedValue(undefined), + deleteById: jest.fn().mockResolvedValue(undefined), + findRecent: jest.fn().mockResolvedValue([]), + }; + const logger = createMockLogger(); + const isDbConnected = jest.fn(() => true); + + const mocks = { + isDbConnected, + jobsRepository, + monitorsRepository, + checksRepository, + checkService, + bufferService, + checkProducer, + checkEvaluator, + geoCheckPipeline, + dispatcher, + helper, + queueWorkersRepository, + logger, + ...overrides?.mocks, + }; + + const worker = new DBQueueWorker( + mocks.logger as any, + mocks.isDbConnected as any, + mocks.jobsRepository as any, + mocks.monitorsRepository as any, + mocks.checksRepository as any, + mocks.checkService as any, + mocks.bufferService as any, + mocks.checkProducer as any, + mocks.checkEvaluator as any, + mocks.geoCheckPipeline as any, + mocks.dispatcher as any, + mocks.helper as any, + mocks.queueWorkersRepository as any, + overrides?.queueMode ?? "worker", + overrides?.queuePrimaryProcesses ?? true, + "worker-1" + ); + return { worker, mocks }; +}; + +describe("DBQueueWorker", () => { + let active: DBQueueWorker | null = null; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(async () => { + if (active) await active.shutdown(); + active = null; + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + const start = async (opts?: Parameters[0]) => { + const ctx = createWorker(opts); + active = ctx.worker; + await ctx.worker.init(); + await jest.advanceTimersByTimeAsync(1); // flush the immediate tick + any job it launched + return ctx; + }; + + // ── init() wiring ───────────────────────────────────────────────────────── + + describe("init", () => { + it("registers the worker in the heartbeat registry on startup", async () => { + const { mocks } = await start({ queueMode: "worker" }); + expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", true); + }); + + it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => { + const { mocks } = await start({ + queueMode: "primary", + queuePrimaryProcesses: false, // isolate reconcile from the polling loops + mocks: { + monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([makeMonitor()]), updateById: jest.fn() }, + }, + }); + + const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(seededTypes).toContain("check"); + const cleanupTypes = mocks.jobsRepository.upsertCleanupJob.mock.calls.map((c: any[]) => c[0].type); + expect(cleanupTypes).toContain("cleanup-orphaned"); + expect(cleanupTypes).toContain("cleanup-retention"); + }); + + it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => { + const geoMonitor = makeMonitor({ type: "http", geoCheckEnabled: true }); + const { mocks } = await start({ + queueMode: "primary", + queuePrimaryProcesses: false, + mocks: { + monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([geoMonitor]), updateById: jest.fn() }, + }, + }); + + const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(seededTypes).toContain("geo-check"); + }); + + it("worker mode does not reconcile (never seeds jobs)", async () => { + const { mocks } = await start({ queueMode: "worker" }); + expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled(); + expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled(); + }); + + it("starts a polling loop per job type, each claiming up to its concurrency cap", async () => { + const { mocks } = await start({ queueMode: "worker" }); + + const claimedTypes = mocks.jobsRepository.claimDueBatch.mock.calls.map((c: any[]) => c[0]); + expect(claimedTypes).toEqual(expect.arrayContaining(["check", "geo-check", "evaluate", "cleanup-orphaned", "cleanup-retention"])); + expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("check", 500, expect.any(Number)); + expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("evaluate", 20, expect.any(Number)); + expect(mocks.jobsRepository.claimDueBatch).toHaveBeenCalledWith("cleanup-orphaned", 1, expect.any(Number)); + }); + + it("does not start polling loops when primary and queuePrimaryProcesses is false", async () => { + const { mocks } = await start({ queueMode: "primary", queuePrimaryProcesses: false }); + expect(mocks.jobsRepository.claimDueBatch).not.toHaveBeenCalled(); + }); + + it("create() constructs the worker and runs init", async () => { + const { mocks } = createWorker(); // reuse the mock set; the un-init'd instance is discarded + const worker = await DBQueueWorker.create( + mocks.logger as any, + mocks.isDbConnected as any, + mocks.jobsRepository as any, + mocks.monitorsRepository as any, + mocks.checksRepository as any, + mocks.checkService as any, + mocks.bufferService as any, + mocks.checkProducer as any, + mocks.checkEvaluator as any, + mocks.geoCheckPipeline as any, + mocks.dispatcher as any, + mocks.helper as any, + mocks.queueWorkersRepository as any, + "worker", + true, + "worker-1" + ); + active = worker; + await jest.advanceTimersByTimeAsync(1); + + expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", true); + }); + }); + + // ── runJob: the per-type execution paths ──────────────────────────────────── + + describe("runJob", () => { + // Hand the "check" loop exactly one job, then nothing, so the assertion target runs once. + const claimOnce = (job: Job) => { + let handed = false; + return jest.fn(async (type: string) => { + if (type === job.type && !handed) { + handed = true; + return [job]; + } + return []; + }); + }; + + it("runs a check job and reschedules it on success", async () => { + const job = makeJob({ type: "check", refId: "m1", intervalMs: 60000 }); + const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } }); + + expect(mocks.monitorsRepository.findByIdLean).toHaveBeenCalledWith("m1"); + expect(mocks.checkProducer.produce).toHaveBeenCalled(); + // The check stage only produces; the buffer arms evaluate once the check is durably stored + expect(mocks.jobsRepository.upsertEvaluate).not.toHaveBeenCalled(); + expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalledWith(job.id, job.nextScheduledAt, job.intervalMs, expect.any(Number)); + expect(mocks.jobsRepository.recordFailure).not.toHaveBeenCalled(); + }); + + it("records a failure (and not a success) when the job handler throws", async () => { + const job = makeJob({ type: "check" }); + const failingProducer = { produce: jest.fn().mockRejectedValue(new Error("network down")) }; + const { mocks } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: failingProducer }, + }); + + expect(mocks.jobsRepository.recordFailure).toHaveBeenCalledWith(job.id, expect.any(Error), expect.any(Number)); + expect(mocks.jobsRepository.recordSuccess).not.toHaveBeenCalled(); + }); + + it("parks a one-shot job (intervalMs null) via recordOneShot instead of rescheduling", async () => { + const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null }); + const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } }); + + expect(mocks.jobsRepository.recordOneShot).toHaveBeenCalledWith(job.id, expect.any(Number)); + expect(mocks.jobsRepository.recordSuccess).not.toHaveBeenCalled(); + }); + + it("evaluate job dispatches each unevaluated check and advances lastEvaluatedAt", async () => { + const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null }); + const checksRepository = { findUnevaluatedByMonitorId: jest.fn().mockResolvedValue([{ id: "c1" }]) }; + const { mocks } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checksRepository }, + }); + + expect(mocks.checkEvaluator.evaluate).toHaveBeenCalled(); + expect(mocks.dispatcher.dispatch).toHaveBeenCalled(); + expect(mocks.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { lastEvaluatedAt: 12345 }); + }); + + it("evaluate job reads the monitor once and threads the post-write monitor across the backlog", async () => { + const job = makeJob({ id: "evaluate:m1", type: "evaluate", intervalMs: null }); + const initialMonitor = makeMonitor(); + const postWriteMonitor = makeMonitor({ status: "down" }); // what updateStatusWindowAndChecks would return after check c1 + const monitorsRepository = { + findByIdLean: jest.fn().mockResolvedValue(initialMonitor), + updateById: jest.fn().mockResolvedValue({}), + }; + const checksRepository = { findUnevaluatedByMonitorId: jest.fn().mockResolvedValue([{ id: "c1" }, { id: "c2" }]) }; + const checkEvaluator = { + evaluate: jest.fn().mockResolvedValue({ monitor: postWriteMonitor, statusChange: { monitor: postWriteMonitor }, decision: {} }), + }; + const { mocks } = await start({ + mocks: { + jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, + monitorsRepository, + checksRepository, + checkEvaluator, + }, + }); + + // The monitor is read once for the whole backlog, not once per check + expect(mocks.monitorsRepository.findByIdLean).toHaveBeenCalledTimes(1); + expect(mocks.checkEvaluator.evaluate).toHaveBeenCalledTimes(2); + // First check evaluates against the freshly-read monitor; the second against the post-write monitor from the first + expect(mocks.checkEvaluator.evaluate.mock.calls[0][2]).toBe(initialMonitor); + expect(mocks.checkEvaluator.evaluate.mock.calls[1][2]).toBe(postWriteMonitor); + }); + + it("cleanup-orphaned job runs the helper's cleanup function", async () => { + const job = makeJob({ id: "cleanup-orphaned", type: "cleanup-orphaned", refId: null, intervalMs: 86400000 }); + const cleanupFn = jest.fn().mockResolvedValue(undefined); + const helper = { + getCleanupOrphanedJob: jest.fn().mockReturnValue(cleanupFn), + getCleanupRetentionJob: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(undefined)), + }; + await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, helper } }); + + expect(cleanupFn).toHaveBeenCalled(); + }); + + it("cleanup-retention job runs the helper's retention function", async () => { + const job = makeJob({ id: "cleanup-retention", type: "cleanup-retention", refId: null, intervalMs: 86400000 }); + const retentionFn = jest.fn().mockResolvedValue(undefined); + const helper = { + getCleanupOrphanedJob: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(undefined)), + getCleanupRetentionJob: jest.fn().mockReturnValue(retentionFn), + }; + await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, helper } }); + + expect(retentionFn).toHaveBeenCalled(); + }); + + it("geo-check job runs the geo pipeline (no evaluate handoff)", async () => { + const job = makeJob({ id: "geo-check:m1", type: "geo-check", refId: "m1", intervalMs: 300000 }); + const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) } } }); + + expect(mocks.geoCheckPipeline.run).toHaveBeenCalled(); + expect(mocks.dispatcher.dispatch).not.toHaveBeenCalled(); + expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalledWith(job.id, job.nextScheduledAt, job.intervalMs, expect.any(Number)); + }); + + it("renews the lock while a slow job is still running", async () => { + const job = makeJob({ type: "check" }); + const gate = deferred<{ status: unknown; check: unknown }>(); + const slowProducer = { produce: jest.fn().mockReturnValue(gate.promise) }; + const { mocks } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: slowProducer }, + }); + + // Job is parked on produce(); push past one renewal interval. + await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS); + expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledWith([job.id], expect.any(Number)); + + // Let it finish; success is still recorded. + gate.resolve({ status: { status: "up" }, check: { id: "c1" } }); + await jest.advanceTimersByTimeAsync(1); + expect(mocks.jobsRepository.recordSuccess).toHaveBeenCalled(); + }); + + it("stops renewing once the lock is lost to another worker", async () => { + const job = makeJob({ type: "check" }); + const gate = deferred<{ status: unknown; check: unknown }>(); + const slowProducer = { produce: jest.fn().mockReturnValue(gate.promise) }; + const jobsRepository = { + ...createWorker().mocks.jobsRepository, + claimDueBatch: claimOnce(job), + renewLocks: jest.fn().mockResolvedValue(0), + }; + const { mocks } = await start({ mocks: { jobsRepository, checkProducer: slowProducer } }); + + await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS); + expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledTimes(1); // lease lost → renewal timer stops + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Lost lock") })); + + // A second interval passes without another renewal attempt. + await jest.advanceTimersByTimeAsync(LOCK_RENEW_MS); + expect(mocks.jobsRepository.renewLocks).toHaveBeenCalledTimes(1); + + gate.resolve({ status: { status: "up" }, check: { id: "c1" } }); + await jest.advanceTimersByTimeAsync(1); + }); + }); + + // ── monitor lifecycle delegation ──────────────────────────────────────────── + + describe("job lifecycle methods", () => { + it("addJob seeds a check row, plus a geo row for geo-enabled monitors", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.addJob("m1", makeMonitor({ geoCheckEnabled: true })); + + const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(types).toContain("check"); + expect(types).toContain("geo-check"); + }); + + it("addJob schedules new jobs to run immediately (no startup jitter)", async () => { + const { worker, mocks } = createWorker(); + active = worker; + const now = Date.now(); + await worker.addJob("m1", makeMonitor({ geoCheckEnabled: true })); + + for (const [seed] of mocks.jobsRepository.upsertJob.mock.calls as any[][]) { + expect(seed.nextScheduledAt).toBe(now); + } + }); + + it("addJob seeds only a check row for non-geo monitors", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.addJob("m1", makeMonitor({ geoCheckEnabled: false })); + + const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(types).toEqual(["check"]); + }); + + it("deleteJob removes every row for the monitor", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.deleteJob(makeMonitor()); + expect(mocks.jobsRepository.deleteById).toHaveBeenCalledWith("m1"); + }); + + it("pauseJob and resumeJob flip the active flag", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.pauseJob(makeMonitor()); + expect(mocks.jobsRepository.setActiveById).toHaveBeenCalledWith("m1", false); + await worker.resumeJob(makeMonitor()); + expect(mocks.jobsRepository.setActiveById).toHaveBeenCalledWith("m1", true); + }); + + it("updateJob reschedules the check row and drops the geo row when geo is disabled", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.updateJob(makeMonitor({ geoCheckEnabled: false, interval: 30000 })); + expect(mocks.jobsRepository.updateScheduleById).toHaveBeenCalledWith("m1", "check", 30000); + expect(mocks.jobsRepository.deleteByIdAndType).toHaveBeenCalledWith("m1", "geo-check"); + }); + + it("updateJob upserts the geo row when geo is enabled", async () => { + const { worker, mocks } = createWorker(); + active = worker; + await worker.updateJob(makeMonitor({ geoCheckEnabled: true })); + const types = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(types).toContain("geo-check"); + expect(mocks.jobsRepository.deleteByIdAndType).not.toHaveBeenCalled(); + }); + }); + + // ── poll backoff + wake() ─────────────────────────────────────────────────── + // + // An idle loop grows its poll interval up to POLL_MAX_MS (5s). wake() fast-paths + // it back so a freshly-enqueued "run now" job doesn't wait out that backoff. + + describe("poll backoff and wake", () => { + const POLL_MAX_MS = 5000; + const claims = (mocks: any, type: string) => mocks.jobsRepository.claimDueBatch.mock.calls.filter((c: any[]) => c[0] === type).length; + + it("backs off the idle poll interval toward POLL_MAX_MS", async () => { + const { mocks } = await start({ queueMode: "worker" }); // claimDueBatch returns [] → always idle + await jest.advanceTimersByTimeAsync(15000); // let the check loop grow to the cap + const before = claims(mocks, "check"); + await jest.advanceTimersByTimeAsync(POLL_MAX_MS * 2); // ~2 polls at a 5s cadence + const delta = claims(mocks, "check") - before; + // At the cap that's ~2 polls; at the un-backed-off 250ms cadence it would be ~40. + expect(delta).toBeGreaterThanOrEqual(1); + expect(delta).toBeLessThanOrEqual(4); + }); + + it("resets the poll interval to POLL_MS once a tick finds work after being idle", async () => { + let busy = false; + const claimDueBatch = jest.fn(async (type: string) => (busy && type === "check" ? [makeJob()] : [])); + const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch } } }); + + await jest.advanceTimersByTimeAsync(15000); // idle → backed off to the 5s cap + busy = true; + await jest.advanceTimersByTimeAsync(POLL_MAX_MS); // the next (backed-off) tick fires, finds work → resets pollMs to POLL_MS + const before = claims(mocks, "check"); + + await jest.advanceTimersByTimeAsync(POLL_MS * 2); // 500ms + + // Polling fast again (~2 in 500ms), not once per 5s — proves the interval reset on finding work. + expect(claims(mocks, "check") - before).toBeGreaterThanOrEqual(1); + }); + + it("does not back off a loop that is at capacity (capacity === 0)", async () => { + // cleanup-orphaned has concurrency 1; hold its one job in-flight so capacity stays 0. + // The backoff condition is `capacity > 0 && jobs.length === 0`, so an at-capacity loop + // must keep polling at POLL_MS (a freed slot should be picked up promptly, not after 5s). + const gate = deferred(); + let handed = false; + const claimDueBatch = jest.fn(async (type: string) => { + if (type === "cleanup-orphaned" && !handed) { + handed = true; + return [makeJob({ id: "cleanup-orphaned", type: "cleanup-orphaned", refId: null, intervalMs: 86400000 })]; + } + return []; + }); + const helper = { + getCleanupOrphanedJob: jest.fn().mockReturnValue(jest.fn(() => gate.promise)), // blocks → keeps the slot full + getCleanupRetentionJob: jest.fn().mockReturnValue(jest.fn().mockResolvedValue(undefined)), + }; + const { mocks } = await start({ mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch }, helper } }); + + const before = claims(mocks, "cleanup-orphaned"); // the blocking job is now in-flight → capacity 0 + await jest.advanceTimersByTimeAsync(1000); + const delta = claims(mocks, "cleanup-orphaned") - before; + + // ~4 polls at POLL_MS; if an at-capacity loop wrongly backed off it would be ~1. + expect(delta).toBeGreaterThanOrEqual(3); + + gate.resolve(); + await jest.advanceTimersByTimeAsync(1); // let the held job finish for a clean shutdown + }); + + it("wake() re-fires an idle, backed-off loop immediately", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + await jest.advanceTimersByTimeAsync(15000); // back off the check loop to the cap + const before = claims(mocks, "check"); + + worker.wake("check"); + await jest.advanceTimersByTimeAsync(1); + + expect(claims(mocks, "check")).toBe(before + 1); // fired now, not 5s later + }); + + it("wake() resets the cadence so the loop polls fast again, not at the 5s cap", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + await jest.advanceTimersByTimeAsync(15000); // backed off to the cap + worker.wake("check"); + await jest.advanceTimersByTimeAsync(1); // immediate tick (still idle → next poll at ~POLL_MS*2) + const before = claims(mocks, "check"); + + await jest.advanceTimersByTimeAsync(POLL_MS * 2); // 500ms + + // Polled again well within the old 5s cap, proving the interval was reset. + expect(claims(mocks, "check")).toBe(before + 1); + }); + + it("wake() is a no-op for a loop that isn't running in this process", async () => { + const { worker, mocks } = await start({ queueMode: "primary", queuePrimaryProcesses: false }); // no loops + expect(() => worker.wake("check")).not.toThrow(); + await jest.advanceTimersByTimeAsync(1); + expect(claims(mocks, "check")).toBe(0); + }); + + it("addJob wakes the check loop so a new monitor's first check runs promptly", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + await jest.advanceTimersByTimeAsync(15000); // backed off to the cap + const before = claims(mocks, "check"); + + await worker.addJob("m2", makeMonitor({ id: "m2" })); + await jest.advanceTimersByTimeAsync(1); + + expect(claims(mocks, "check")).toBe(before + 1); + }); + + it("resumeJob wakes the check loop so a resumed monitor runs promptly", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + await jest.advanceTimersByTimeAsync(15000); // backed off to the cap + const before = claims(mocks, "check"); + + await worker.resumeJob(makeMonitor()); + await jest.advanceTimersByTimeAsync(1); + + expect(claims(mocks, "check")).toBe(before + 1); + }); + }); + + // ── shutdown ──────────────────────────────────────────────────────────────── + + describe("shutdown", () => { + it("deregisters the worker and stops the polling loops", async () => { + const { worker, mocks } = createWorker({ queueMode: "worker" }); + await worker.init(); + await jest.advanceTimersByTimeAsync(1); + const claimsAfterInit = mocks.jobsRepository.claimDueBatch.mock.calls.length; + + await worker.shutdown(); + expect(mocks.queueWorkersRepository.deleteById).toHaveBeenCalledWith("worker-1"); + + // Loops must not re-arm after shutdown. + await jest.advanceTimersByTimeAsync(POLL_MS * 4); + expect(mocks.jobsRepository.claimDueBatch.mock.calls.length).toBe(claimsAfterInit); + // already shut down; keep afterEach from double-shutting-down + active = null; + }); + }); + + // ── drain ───────────────────────────────────────────────────────────────────── + + describe("drain", () => { + // Hand the "check" loop exactly one job, then nothing. + const claimOnce = (job: Job) => { + let handed = false; + return jest.fn(async (type: string) => { + if (type === job.type && !handed) { + handed = true; + return [job]; + } + return []; + }); + }; + + it("stops the loops and flushes the buffer when nothing is in flight", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + const claimsBefore = mocks.jobsRepository.claimDueBatch.mock.calls.length; + + await worker.drain(); + + expect(mocks.bufferService.shutdown).toHaveBeenCalledTimes(1); + expect(mocks.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("drained") })); + // Loops must not claim again after draining. + await jest.advanceTimersByTimeAsync(POLL_MS * 4); + expect(mocks.jobsRepository.claimDueBatch.mock.calls.length).toBe(claimsBefore); + }); + + it("waits for an in-flight job to finish before flushing", async () => { + const job = makeJob({ type: "check" }); + const gate = deferred<{ status: unknown; check: unknown }>(); + const slowProducer = { produce: jest.fn().mockReturnValue(gate.promise) }; + const { worker, mocks } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: slowProducer }, + }); + + // Job is parked on produce(); drain must not flush while it is still in flight. + const draining = worker.drain(); + await jest.advanceTimersByTimeAsync(DRAIN_POLL_MS * 3); + expect(mocks.bufferService.shutdown).not.toHaveBeenCalled(); + + // Let the job finish; drain observes inFlight == 0, then flushes. + gate.resolve({ status: { status: "up" }, check: { id: "c1" } }); + await jest.advanceTimersByTimeAsync(DRAIN_POLL_MS); + await draining; + expect(mocks.bufferService.shutdown).toHaveBeenCalledTimes(1); + }); + + it("times out after DRAIN_TIMEOUT_MS, warns, and still flushes", async () => { + const job = makeJob({ type: "check" }); + const gate = deferred<{ status: unknown; check: unknown }>(); + const stuckProducer = { produce: jest.fn().mockReturnValue(gate.promise) }; + const { worker, mocks } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOnce(job) }, checkProducer: stuckProducer }, + }); + + const draining = worker.drain(); + await jest.advanceTimersByTimeAsync(DRAIN_TIMEOUT_MS + DRAIN_POLL_MS); + await draining; + + expect(mocks.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("timed out") })); + expect(mocks.bufferService.shutdown).toHaveBeenCalledTimes(1); + + gate.resolve({ status: { status: "up" }, check: { id: "c1" } }); // release the stuck job for cleanup + }); + }); + + // ── getHealth ─────────────────────────────────────────────────────────────────── + + describe("getHealth", () => { + // Hand the "check" loop exactly one job, then nothing — keeps it parked in-flight. + const claimOneCheck = (job: Job) => { + let handed = false; + return jest.fn(async (type: string) => { + if (type === job.type && !handed) { + handed = true; + return [job]; + } + return []; + }); + }; + + it("reports un-init state before init(): not complete, not draining, never ticked", () => { + const { worker } = createWorker({ queueMode: "worker" }); + active = worker; // afterEach shutdown() is safe on an un-init'd worker + expect(worker.getHealth()).toMatchObject({ + workerId: "worker-1", + mode: "worker", + initComplete: false, + draining: false, + lastTickAt: null, + inFlight: 0, + }); + }); + + it("flips initComplete and stamps lastTickAt once the loops run", async () => { + const { worker } = await start({ queueMode: "worker" }); + const health = worker.getHealth(); + expect(health.initComplete).toBe(true); + expect(typeof health.lastTickAt).toBe("number"); + }); + + it("reflects the injected dbConnected probe", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + expect(worker.getHealth().dbConnected).toBe(true); + mocks.isDbConnected.mockReturnValue(false); + expect(worker.getHealth().dbConnected).toBe(false); + }); + + it("sets draining true after drain()", async () => { + const { worker } = await start({ queueMode: "worker" }); + expect(worker.getHealth().draining).toBe(false); + await worker.drain(); + expect(worker.getHealth().draining).toBe(true); + }); + + it("counts in-flight jobs while they run and releases on completion", async () => { + const job = makeJob({ type: "check" }); + const gate = deferred<{ status: unknown; check: unknown }>(); + const slowProducer = { produce: jest.fn().mockReturnValue(gate.promise) }; + const { worker } = await start({ + mocks: { jobsRepository: { ...createWorker().mocks.jobsRepository, claimDueBatch: claimOneCheck(job) }, checkProducer: slowProducer }, + }); + + expect(worker.getHealth().inFlight).toBe(1); // parked on produce() + + gate.resolve({ status: { status: "up" }, check: { id: "c1" } }); + await jest.advanceTimersByTimeAsync(1); + expect(worker.getHealth().inFlight).toBe(0); + }); + }); + + // ── metrics accessors ─────────────────────────────────────────────────────────── + + describe("metrics accessors", () => { + it("countDueBacklog delegates to the repo with the current time", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + mocks.jobsRepository.countDueBacklog.mockResolvedValue(7); + + expect(await worker.countDueBacklog()).toBe(7); + expect(mocks.jobsRepository.countDueBacklog).toHaveBeenCalledWith(expect.any(Number)); + }); + + it("countAliveWorkers counts only recently-seen nodes that process jobs", async () => { + const { worker, mocks } = await start({ queueMode: "worker" }); + mocks.queueWorkersRepository.findRecent.mockResolvedValue([ + { processesJobs: true }, + { processesJobs: true }, + { processesJobs: false }, // scheduler-only primary: alive but not a processor + ]); + + expect(await worker.countAliveWorkers()).toBe(2); + }); + }); + + // ── flushQueues ─────────────────────────────────────────────────────────────── + + describe("flushQueues", () => { + it("restarts the worker and reports success", async () => { + const { worker, mocks } = createWorker({ queueMode: "worker" }); + active = worker; + await worker.init(); + await jest.advanceTimersByTimeAsync(1); + + const result = await worker.flushQueues(); + await jest.advanceTimersByTimeAsync(1); + + expect(result).toEqual({ success: true }); + expect(mocks.queueWorkersRepository.deleteById).toHaveBeenCalledWith("worker-1"); // shutdown leg + expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledTimes(2); // init + re-init + }); + }); + + // ── getMetrics ────────────────────────────────────────────────────────────── + + describe("getMetrics", () => { + it("aggregates run/fail counts, active leases, and failing jobs across all rows", async () => { + const now = Date.now(); + const rows = [ + makeJob({ id: "a", runCount: 5, failCount: 0, lockedBy: "w", lockedUntil: now + 10000 }), // active lease + makeJob({ id: "b", runCount: 2, failCount: 3, lastFailReason: "boom", lockedBy: null, lockedUntil: null }), // failing + makeJob({ id: "c", runCount: 1, failCount: 0, lockedBy: "w", lockedUntil: now - 10000 }), // expired lease, not active + ]; + const { worker, mocks } = createWorker(); + active = worker; + mocks.jobsRepository.findAll.mockResolvedValue(rows); + mocks.queueWorkersRepository.findRecent.mockResolvedValue([ + { workerId: "w", processesJobs: true }, + { workerId: "scheduler", processesJobs: false }, // scheduler-only primary excluded from metrics + ]); + + const metrics = await worker.getMetrics(); + + expect(metrics.jobs).toBe(3); + expect(metrics.totalRuns).toBe(8); + expect(metrics.totalFailures).toBe(3); + expect(metrics.activeJobs).toBe(1); + expect(metrics.failingJobs).toBe(1); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ failCount: 3, failReason: "boom" })); + expect(metrics.workers).toEqual([{ workerId: "w", processesJobs: true }]); + }); + }); + + // ── getJobs ───────────────────────────────────────────────────────────────── + + describe("getJobs", () => { + it("maps rows to summaries and passes the total count through", async () => { + const { worker, mocks } = createWorker(); + active = worker; + mocks.jobsRepository.findPage.mockResolvedValue({ + jobs: [makeJob({ id: "check:m1", refId: "m1", type: "check", intervalMs: 60000 })], + count: 1, + }); + + const page = await worker.getJobs({ page: 0, rowsPerPage: 10 }); + + expect(page.count).toBe(1); + expect(page.jobs).toHaveLength(1); + expect(page.jobs[0]).toEqual(expect.objectContaining({ monitorId: "m1", monitorType: "check", monitorInterval: 60000 })); + }); + + it("falls back to the job id for monitorId when refId is null (global jobs)", async () => { + const { worker, mocks } = createWorker(); + active = worker; + mocks.jobsRepository.findPage.mockResolvedValue({ + jobs: [makeJob({ id: "cleanup-orphaned", refId: null, type: "cleanup-orphaned" })], + count: 1, + }); + + const page = await worker.getJobs({ page: 0, rowsPerPage: 10 }); + expect(page.jobs[0].monitorId).toBe("cleanup-orphaned"); + }); + }); +}); diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts index ae57613a1..07962a68e 100644 --- a/server/test/unit/services/diagnosticService.test.ts +++ b/server/test/unit/services/diagnosticService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; -import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts"; -import type { IDb } from "../../../src/db/IDb.ts"; +import { DiagnosticService } from "../../../src/domain/diagnostics/diagnostic.service.ts"; +import type { IDb } from "../../../src/db/db.interface.ts"; import type { Mongoose } from "mongoose"; // ── Test helpers ───────────────────────────────────────────────────────────── @@ -104,9 +104,8 @@ describe("DiagnosticService", () => { it("returns only the slimmed diagnostic shape (no serverStatus/serverInfo/memoryUsage)", async () => { const service = new DiagnosticService(makeDb()); const promise = service.getSystemStats(); - // getCPUUsagePercentage waits 1s, then event loop delay waits 0ms - jest.advanceTimersByTime(1000); - await jest.advanceTimersByTimeAsync(10); + // getCPUUsagePercentage waits 1s, the first mongo ops sample waits another 1s, then event loop delay waits 0ms + await jest.advanceTimersByTimeAsync(2500); const result = await promise; expect(Object.keys(result).sort()).toEqual(["cpuUsage", "eventLoopDelayMs", "mongoStats", "osStats", "uptimeMs", "v8HeapStats"].sort()); @@ -131,11 +130,24 @@ describe("DiagnosticService", () => { }); const service = new DiagnosticService(db); const promise = service.getSystemStats(); - jest.advanceTimersByTime(1000); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(2500); const result = await promise; - expect(Object.keys(result.mongoStats).sort()).toEqual(["collections", "dbName", "host", "port", "readyState", "totalSize"].sort()); + expect(Object.keys(result.mongoStats).sort()).toEqual( + [ + "collections", + "dbName", + "host", + "port", + "readyState", + "totalSize", + "readsPerSecond", + "insertsPerSecond", + "updatesPerSecond", + "deletesPerSecond", + "writesPerSecond", + ].sort() + ); expect(result.mongoStats.totalSize).toBe(9999); expect(result.mongoStats.collections).toHaveLength(2); expect(result.mongoStats.collections[0]).toEqual({ @@ -147,6 +159,40 @@ describe("DiagnosticService", () => { }); }); + it("derives mongoStats.totalSize from storageSize + indexSize when the server omits it (e.g. Atlas shared tier)", async () => { + const db = makeDb({ + collections: [{ collectionName: "monitors" }], + dbStats: { storageSize: 376832, indexSize: 1155072 }, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + await jest.advanceTimersByTimeAsync(2500); + const result = await promise; + + expect(result.mongoStats.totalSize).toBe(376832 + 1155072); + expect(Number.isFinite(result.mongoStats.totalSize)).toBe(true); + }); + + it("derives collection totalSize and defaults missing size fields to 0 when collStats omits them", async () => { + const db = makeDb({ + collections: [{ collectionName: "checks" }], + collStats: { storageSize: 200 }, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + await jest.advanceTimersByTimeAsync(2500); + const result = await promise; + + const coll = result.mongoStats.collections[0]; + expect(coll).toMatchObject({ + name: "checks", + storageSize: 200, + totalIndexSize: 0, + totalSize: 200, // storageSize (200) + totalIndexSize (0) + }); + expect(Number.isFinite(coll?.totalSize)).toBe(true); + }); + it("includes bucketCount on timeseries collections, omits it elsewhere", async () => { const db = makeDb({ collections: [{ collectionName: "checks" }], @@ -154,8 +200,7 @@ describe("DiagnosticService", () => { }); const service = new DiagnosticService(db); const promise = service.getSystemStats(); - jest.advanceTimersByTime(1000); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(2500); const result = await promise; expect(result.mongoStats.collections[0]?.bucketCount).toBe(12); @@ -163,8 +208,7 @@ describe("DiagnosticService", () => { const db2 = makeDb({ collections: [{ collectionName: "monitors" }] }); const service2 = new DiagnosticService(db2); const promise2 = service2.getSystemStats(); - jest.advanceTimersByTime(1000); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(2500); const result2 = await promise2; expect(result2.mongoStats.collections[0]).not.toHaveProperty("bucketCount"); }); @@ -199,8 +243,7 @@ describe("DiagnosticService", () => { performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any; const promise = service.getSystemStats(); - jest.advanceTimersByTime(1000); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(2500); const result = await promise; expect(result.eventLoopDelayMs).toBe(0); diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index d4d275ad8..fbfdf15fd 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { EmailService } from "../../../src/service/infrastructure/emailService.ts"; +import { EmailService } from "../../../src/service/emailService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; -import type { EmailTransportConfig } from "../../../src/types/index.ts"; +import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; +import type { EmailTransportConfig } from "../../../src/domain/app-settings/app-settings.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/geoChecksService.test.ts b/server/test/unit/services/geoChecksService.test.ts index ab03d319e..8e50fa3e8 100644 --- a/server/test/unit/services/geoChecksService.test.ts +++ b/server/test/unit/services/geoChecksService.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts"; +import { GeoChecksService } from "../../../src/domain/geo-checks/geo-check.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { Monitor } from "../../../src/types/index.ts"; -import type { GeoCheckResult } from "../../../src/types/geoCheck.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { GeoCheckResult } from "../../../src/domain/geo-checks/geo-check.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/globalPingService.test.ts b/server/test/unit/services/globalPingService.test.ts index 78d195b0f..376653110 100644 --- a/server/test/unit/services/globalPingService.test.ts +++ b/server/test/unit/services/globalPingService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { GeoContinent } from "../../../src/types/geoCheck.ts"; +import type { GeoContinent } from "../../../src/domain/geo-checks/geo-check.type.ts"; // ── got mock ───────────────────────────────────────────────────────────────── @@ -15,7 +15,7 @@ jest.unstable_mockModule("got", () => ({ })); // Dynamic import AFTER mock registration -const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts"); +const { GlobalPingService } = await import("../../../src/service/globalPingService.ts"); // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/healthServer.test.ts b/server/test/unit/services/healthServer.test.ts new file mode 100644 index 000000000..ccf9d535c --- /dev/null +++ b/server/test/unit/services/healthServer.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it } from "@jest/globals"; +import http from "http"; +import net from "net"; +import { HealthServer } from "../../../src/worker/worker.health-server.ts"; +import type { WorkerHealth } from "../../../src/worker/worker.interface.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +// ── Notes ────────────────────────────────────────────────────────────────────── +// +// HealthServer is a dumb bare-http listener over the worker's getHealth() snapshot: +// it maps the snapshot to 200/503 per endpoint and serves the snapshot as the body. +// We drive it end-to-end with real sockets and a stub worker, so REAL timers are used +// (Date.now() drives the /livez staleness check) — no fake timers in this file. +// +// listen() binds `Number(port) || 52346`, so "0" would resolve to 52346, not an +// ephemeral port. We discover a free port first and pass it as a concrete string. + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(0, () => { + const addr = srv.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + srv.close(() => resolve(port)); + }); + }); + +const get = (port: number, path: string): Promise<{ status: number; body: string; contentType: string }> => + new Promise((resolve, reject) => { + http + .get({ host: "127.0.0.1", port, path }, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body, contentType: String(res.headers["content-type"] ?? "") })); + }) + .on("error", reject); + }); + +// Fresh, fully-healthy snapshot; override per test. lastTickAt is stamped at call time +// so it is "recent" relative to the request that follows a moment later. +const makeHealth = (over?: Partial): WorkerHealth => ({ + workerId: "worker-1", + mode: "worker", + dbConnected: true, + initComplete: true, + draining: false, + lastTickAt: Date.now(), + inFlight: 0, + ...over, +}); + +describe("HealthServer", () => { + let active: HealthServer | null = null; + + afterEach(async () => { + if (active) await active.close(); + active = null; + }); + + // The metrics endpoint also calls countDueBacklog()/countAliveWorkers(); default them + // to 0 and let tests override (including to a rejection, to exercise the 500 path). + const setup = async (health: WorkerHealth, workerOverrides: Record = {}) => { + const port = await freePort(); + const worker = { + getHealth: () => health, + countDueBacklog: async () => 0, + countAliveWorkers: async () => 0, + ...workerOverrides, + } as any; + const server = new HealthServer(createMockLogger(), String(port), worker); + await server.listen(); + active = server; + return { port }; + }; + + describe("/livez", () => { + it("200 when init complete and a tick is recent", async () => { + const { port } = await setup(makeHealth()); + expect((await get(port, "/livez")).status).toBe(200); + }); + + it("503 before init completes", async () => { + const { port } = await setup(makeHealth({ initComplete: false })); + expect((await get(port, "/livez")).status).toBe(503); + }); + + it("503 when it has never ticked", async () => { + const { port } = await setup(makeHealth({ lastTickAt: null })); + expect((await get(port, "/livez")).status).toBe(503); + }); + + it("503 when the last tick is stale and not draining", async () => { + const { port } = await setup(makeHealth({ lastTickAt: Date.now() - 31_000 })); + expect((await get(port, "/livez")).status).toBe(503); + }); + + it("200 when draining even if ticks are stale (drain must read as live)", async () => { + const { port } = await setup(makeHealth({ draining: true, lastTickAt: Date.now() - 60_000 })); + expect((await get(port, "/livez")).status).toBe(200); + }); + }); + + describe("/readyz", () => { + it("200 when init complete, db connected, and not draining", async () => { + const { port } = await setup(makeHealth()); + expect((await get(port, "/readyz")).status).toBe(200); + }); + + it("503 while draining", async () => { + const { port } = await setup(makeHealth({ draining: true })); + expect((await get(port, "/readyz")).status).toBe(503); + }); + + it("503 when the db is disconnected", async () => { + const { port } = await setup(makeHealth({ dbConnected: false })); + expect((await get(port, "/readyz")).status).toBe(503); + }); + + it("503 before init completes", async () => { + const { port } = await setup(makeHealth({ initComplete: false })); + expect((await get(port, "/readyz")).status).toBe(503); + }); + }); + + describe("routing & body", () => { + it("serves the WorkerHealth snapshot as the JSON body", async () => { + const { port } = await setup(makeHealth({ inFlight: 3 })); + const res = await get(port, "/readyz"); + expect(JSON.parse(res.body)).toMatchObject({ workerId: "worker-1", mode: "worker", inFlight: 3 }); + }); + + it("404s unknown paths", async () => { + const { port } = await setup(makeHealth()); + expect((await get(port, "/nope")).status).toBe(404); + }); + + it("ignores query params when matching the route", async () => { + const { port } = await setup(makeHealth()); + expect((await get(port, "/livez?probe=1")).status).toBe(200); + }); + }); + + describe("/metrics", () => { + it("200 with prometheus content-type and all four gauges", async () => { + const { port } = await setup(makeHealth({ inFlight: 3, draining: false }), { + countDueBacklog: async () => 5, + countAliveWorkers: async () => 2, + }); + const res = await get(port, "/metrics"); + + expect(res.status).toBe(200); + expect(res.contentType).toMatch(/text\/plain/); + expect(res.body).toContain("# TYPE checkmate_worker_due_backlog gauge"); + expect(res.body).toContain("checkmate_worker_jobs_in_flight 3"); + expect(res.body).toContain("checkmate_worker_due_backlog 5"); + expect(res.body).toContain("checkmate_worker_alive_total 2"); + expect(res.body).toContain("checkmate_worker_draining 0"); + }); + + it("reports draining as 1 when the worker is draining", async () => { + const { port } = await setup(makeHealth({ draining: true })); + expect((await get(port, "/metrics")).body).toContain("checkmate_worker_draining 1"); + }); + + it("500 when a metrics query fails", async () => { + const { port } = await setup(makeHealth(), { + countDueBacklog: async () => { + throw new Error("db down"); + }, + }); + expect((await get(port, "/metrics")).status).toBe(500); + }); + }); + + describe("listen", () => { + it("walks to the next port when the configured one is in use", async () => { + const base = await freePort(); + // Hold the base port so the health server's first bind hits EADDRINUSE. + const blocker = net.createServer(); + await new Promise((resolve) => blocker.listen(base, resolve)); + + try { + const worker = { getHealth: () => makeHealth(), countDueBacklog: async () => 0, countAliveWorkers: async () => 0 } as any; + const server = new HealthServer(createMockLogger(), String(base), worker); + await server.listen(); + active = server; + + // base is still held by the blocker, so the health server retried onto a higher port and is reachable there. + const addr = server.address(); + const bound = typeof addr === "object" && addr ? addr.port : 0; + expect(bound).toBeGreaterThan(base); + expect((await get(bound, "/livez")).status).toBe(200); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + }); +}); diff --git a/server/test/unit/services/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts index 4274f67d8..aa4fffce8 100644 --- a/server/test/unit/services/incidentService.test.ts +++ b/server/test/unit/services/incidentService.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { IncidentService } from "../../../src/service/business/incidentService.ts"; +import { IncidentService } from "../../../src/domain/incidents/incident.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts"; -import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; -import type { Monitor } from "../../../src/types/monitor.ts"; -import type { Incident } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; +import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IUsersRepository } from "../../../src/domain/users/user.repository.interface.ts"; +import type { INotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { Incident } from "../../../src/domain/incidents/incident.type.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/inviteService.test.ts b/server/test/unit/services/inviteService.test.ts index da29d1834..c050d91e6 100644 --- a/server/test/unit/services/inviteService.test.ts +++ b/server/test/unit/services/inviteService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { InviteService } from "../../../src/service/business/inviteService.ts"; -import type { Invite, UserRole } from "../../../src/types/index.ts"; +import { InviteService } from "../../../src/domain/invites/invite.service.ts"; +import type { Invite } from "../../../src/domain/invites/invite.type.ts"; +import type { UserRole } from "../../../src/domain/users/user.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/jobScheduler.test.ts b/server/test/unit/services/jobScheduler.test.ts new file mode 100644 index 000000000..90d7a2b99 --- /dev/null +++ b/server/test/unit/services/jobScheduler.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { JobScheduler } from "../../../src/worker/worker.job-scheduler.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { QueueMode } from "../../../src/domain/app-settings/app-settings.type.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +// ── Notes ────────────────────────────────────────────────────────────────────── +// +// JobScheduler is the scheduler-only primary base class: it owns the queue +// registry/heartbeat AND, on a primary, the reconcile that seeds the jobs +// collection — scheduling is its job whether or not it also processes. It runs no +// polling loops and has no buffer, so drain() is a no-op beyond setting the stop +// flag — it must NOT tear down (clear timers or deregister the worker); that stays +// in shutdown(). The processing tier (poll loops, buffer flush) lives in +// DBQueueWorker and is covered separately. + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + isActive: true, + geoCheckEnabled: false, + geoCheckInterval: 300000, + lastEvaluatedAt: 0, + ...overrides, + }) as Monitor; + +const createScheduler = (overrides?: { queueMode?: QueueMode; mocks?: Record }) => { + const jobsRepository = { + upsertJob: jest.fn().mockResolvedValue(true), + upsertCleanupJob: jest.fn().mockResolvedValue(true), + }; + const queueWorkersRepository = { + upsert: jest.fn().mockResolvedValue(undefined), + deleteById: jest.fn().mockResolvedValue(undefined), + }; + const monitorsRepository = { + findAll: jest.fn().mockResolvedValue([]), + }; + const logger = createMockLogger(); + const mocks = { jobsRepository, queueWorkersRepository, monitorsRepository, logger, ...overrides?.mocks }; + const scheduler = new JobScheduler( + mocks.jobsRepository as any, + mocks.queueWorkersRepository as any, + mocks.monitorsRepository as any, + overrides?.queueMode ?? "primary", + mocks.logger as any, + "worker-1" + ); + return { scheduler, mocks }; +}; + +describe("JobScheduler", () => { + describe("init", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(async () => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it("registers the worker in the heartbeat registry on startup", async () => { + const { scheduler, mocks } = createScheduler({ queueMode: "primary" }); + await scheduler.init(); + expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "primary", false); + }); + + it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => { + const { scheduler, mocks } = createScheduler({ + queueMode: "primary", + mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + }); + await scheduler.init(); + + const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(seededTypes).toContain("check"); + const cleanupTypes = mocks.jobsRepository.upsertCleanupJob.mock.calls.map((c: any[]) => c[0].type); + expect(cleanupTypes).toContain("cleanup-orphaned"); + expect(cleanupTypes).toContain("cleanup-retention"); + }); + + it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => { + const { scheduler, mocks } = createScheduler({ + queueMode: "primary", + mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, + }); + await scheduler.init(); + + const seededTypes = mocks.jobsRepository.upsertJob.mock.calls.map((c: any[]) => c[0].type); + expect(seededTypes).toContain("geo-check"); + }); + + it("worker mode heartbeats but does not reconcile (no monitor reads, no seeds)", async () => { + const { scheduler, mocks } = createScheduler({ + queueMode: "worker", + mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + }); + await scheduler.init(); + + expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", false); + expect(mocks.monitorsRepository.findAll).not.toHaveBeenCalled(); + expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled(); + expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled(); + }); + + it("arms a heartbeat interval timer that shutdown() clears and deregisters", async () => { + const { scheduler, mocks } = createScheduler({ queueMode: "primary" }); + await scheduler.init(); + expect((scheduler as any).timers.has("heartbeat")).toBe(true); + + await scheduler.shutdown(); + expect((scheduler as any).timers.has("heartbeat")).toBe(false); + expect(mocks.queueWorkersRepository.deleteById).toHaveBeenCalledWith("worker-1"); + }); + }); + + describe("drain", () => { + it("sets the stop and draining flags without tearing down (no deregister)", async () => { + const { scheduler, mocks } = createScheduler(); + + await scheduler.drain(); + + expect((scheduler as any).stopped).toBe(true); + expect((scheduler as any).draining).toBe(true); + // drain must not do shutdown's teardown work + expect(mocks.queueWorkersRepository.deleteById).not.toHaveBeenCalled(); + }); + + it("stops wake() from arming a tick once drained", async () => { + const { scheduler } = createScheduler(); + const tick = jest.fn(); + (scheduler as any).tickFns.set("check", tick); + + await scheduler.drain(); + scheduler.wake("check"); + + expect((scheduler as any).timers.has("check")).toBe(false); + }); + }); +}); diff --git a/server/test/unit/services/lessSimpleQueue.test.ts b/server/test/unit/services/lessSimpleQueue.test.ts deleted file mode 100644 index 6351dc5d5..000000000 --- a/server/test/unit/services/lessSimpleQueue.test.ts +++ /dev/null @@ -1,791 +0,0 @@ -import { describe, expect, it, jest } from "@jest/globals"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { Monitor } from "../../../src/types/monitor.ts"; - -// ── Mock Scheduler / MongoStore ────────────────────────────────────────────── - -const createScheduler = () => { - const listeners: Record = {}; - return { - workerId: "host-1:1234:abcd", - on: jest.fn((event: string, cb: Function) => { - listeners[event] = listeners[event] || []; - listeners[event].push(cb); - }), - off: jest.fn((event: string, cb: Function) => { - listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb); - }), - emit: (event: string, ...args: unknown[]) => { - (listeners[event] || []).forEach((cb) => cb(...args)); - }, - start: jest.fn().mockResolvedValue(true), - stop: jest.fn().mockResolvedValue(true), - addTemplate: jest.fn(), - addJob: jest.fn(), - addJobs: jest.fn().mockResolvedValue({ inserted: 0, upserted: 0, modified: 0, failures: [] }), - removeJob: jest.fn(), - getJob: jest.fn().mockResolvedValue(null), - getJobs: jest.fn().mockResolvedValue([]), - countJobs: jest.fn().mockResolvedValue(0), - getStats: jest.fn().mockResolvedValue({ jobs: 0, activeJobs: 0, failingJobs: 0, totalRuns: 0, totalFailures: 0, jobsWithFailures: [] }), - pauseJob: jest.fn().mockResolvedValue(true), - resumeJob: jest.fn().mockResolvedValue(true), - updateJob: jest.fn(), - flushJobs: jest.fn().mockResolvedValue(true), - }; -}; - -const mockSchedulerInstance = createScheduler(); -const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance); -const MockMongoStore = jest.fn(); - -jest.unstable_mockModule("less-simple-scheduler", () => ({ - Scheduler: MockScheduler, - MongoStore: MockMongoStore, -})); - -const { LessSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/LessSimpleQueue.ts"); - -const createQueueHelper = () => ({ - getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()), -}); - -const createMonitorsRepo = () => ({ - findAll: jest.fn().mockResolvedValue([]), -}); - -const createWorkersRepo = () => ({ - upsert: jest.fn().mockResolvedValue(undefined), - findRecent: jest.fn().mockResolvedValue([]), - deleteById: jest.fn().mockResolvedValue(undefined), -}); - -const createQueue = (overrides?: Record) => { - const logger = createMockLogger(); - const helper = createQueueHelper(); - const monitorsRepository = createMonitorsRepo(); - const workersRepository = createWorkersRepo(); - const scheduler = createScheduler(); - - const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, queueMode: "primary", ...overrides }; - - const queue = new LessSimpleQueue( - defaults.logger as any, - defaults.helper as any, - defaults.monitorsRepository as any, - defaults.workersRepository as any, - defaults.scheduler as any, - defaults.queueMode as any - ); - return { queue, ...defaults }; -}; - -const makeMonitor = (overrides?: Partial): Monitor => - ({ - id: "mon-1", - teamId: "team-1", - type: "http", - interval: 60000, - isActive: true, - geoCheckEnabled: false, - geoCheckInterval: 300000, - geoCheckLocations: [], - ...overrides, - }) as Monitor; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe("LessSimpleQueue", () => { - describe("serviceName", () => { - it("returns JobQueue", () => { - const { queue } = createQueue(); - expect(queue.serviceName).toBe("JobQueue"); - }); - }); - - // ── init (primary) ───────────────────────────────────────────────────────── - - describe("init - primary mode", () => { - it("starts scheduler, registers all templates, seeds monitors and system jobs", async () => { - const { queue, scheduler, monitorsRepository } = createQueue(); - - const result = await queue.init(); - - expect(result).toBe(true); - expect(scheduler.start).toHaveBeenCalled(); - expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); - expect(monitorsRepository.findAll).toHaveBeenCalled(); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); - }); - - it("registers existing monitors in a single bulk write", async () => { - const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; - const { queue, scheduler, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); - - await queue.init(); - - expect(scheduler.addJobs).toHaveBeenCalledTimes(1); - expect(scheduler.addJobs).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ id: "m1", template: "monitor-job", jitter: true }), - expect.objectContaining({ id: "m2", template: "monitor-job", jitter: true }), - ]) - ); - }); - - it("returns true and skips system jobs when findAll returns null", async () => { - const { queue, monitorsRepository, scheduler } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockResolvedValue(null); - - const result = await queue.init(); - - expect(result).toBe(true); - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); - }); - - it("returns false and logs error on failure", async () => { - const { queue, logger, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down")); - - const result = await queue.init(); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") })); - }); - - it("logs error with String(error) for non-Error exceptions", async () => { - const { queue, logger, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error"); - - const result = await queue.init(); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") })); - }); - }); - - // ── init (worker) ────────────────────────────────────────────────────────── - - describe("init - worker mode", () => { - it("starts scheduler and registers all templates", async () => { - const { queue, scheduler } = createQueue(); - - const result = await queue.init("worker"); - - expect(result).toBe(true); - expect(scheduler.start).toHaveBeenCalled(); - expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); - }); - - it("does NOT seed monitor jobs (no findAll, primary populates the queue)", async () => { - const { queue, monitorsRepository } = createQueue(); - - await queue.init("worker"); - - expect(monitorsRepository.findAll).not.toHaveBeenCalled(); - }); - - it("does NOT seed cleanup system jobs", async () => { - const { queue, scheduler } = createQueue(); - - await queue.init("worker"); - - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); - expect(scheduler.addJob).not.toHaveBeenCalled(); - }); - - it("logs the initialized worker with its workerId", async () => { - const { queue, logger } = createQueue(); - - await queue.init("worker"); - - expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Worker queue: host-1:1234:abcd initialized" })); - }); - }); - - // ── registerListeners ────────────────────────────────────────────────────── - - describe("registerListeners (via constructor)", () => { - it("logs on scheduler:start", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("scheduler:start", "host-1:1234:abcd"); - expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); - }); - - it("logs on scheduler:stop", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("scheduler:stop", "host-1:1234:abcd"); - expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); - }); - - it("logs on scheduler:error with Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("scheduler:error", "w1", new Error("boom")); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: boom" })); - }); - - it("logs on scheduler:error with non-Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("scheduler:error", "w1", "string error"); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: string error", stack: undefined })); - }); - - it("logs on job:locked", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:locked", "w1", { id: "m1" }); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 locked" })); - }); - - it("logs on job:abort", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:abort", "w1", { id: "m1" }, "timeout"); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 aborted: timeout" })); - }); - - it("logs on job:attempt", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:attempt", "w1", { id: "m1" }, 2); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 attempt 2" })); - }); - - it("logs on job:complete", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:complete", "w1", { id: "m1" }); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 completed successfully" })); - }); - - it("logs on job:exhausted with Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:exhausted", "w1", { id: "m1" }, new Error("gave up")); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up" })); - }); - - it("logs on job:exhausted with non-Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:exhausted", "w1", { id: "m1" }, "gave up"); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up", stack: undefined })); - }); - - it("logs on job:fail with Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:fail", "w1", { id: "m1" }, new Error("oops"), 1); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops" })); - }); - - it("logs on job:fail with non-Error", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:fail", "w1", { id: "m1" }, "oops", 1); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops", stack: undefined })); - }); - - it("logs on job:start", () => { - const { scheduler, logger } = createQueue(); - scheduler.emit("job:start", "w1", { id: "m1" }); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 started" })); - }); - }); - - // ── addJob ───────────────────────────────────────────────────────────────── - - describe("addJob", () => { - it("adds monitor job to scheduler", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor()); - expect(scheduler.addJobs).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })]) - ); - }); - - it("adds geo check job when geoCheckEnabled and type supports it", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 })); - expect(scheduler.addJobs).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })]) - ); - }); - - it("skips geo job when type does not support geo checks", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" })); - expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })])); - }); - - it("skips geo job when geoCheckEnabled is false", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" })); - expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })])); - }); - }); - - // ── deleteJob ────────────────────────────────────────────────────────────── - - describe("deleteJob", () => { - it("removes monitor job", async () => { - const { queue, scheduler } = createQueue(); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1"); - }); - - it("removes geo job if it exists", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("does not remove geo job if it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - }); - - // ── pauseJob ─────────────────────────────────────────────────────────────── - - describe("pauseJob", () => { - it("pauses monitor and geo job when geo exists", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.pauseJob(makeMonitor()); - - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); - }); - - it("does not pause geo job when it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.pauseJob(makeMonitor()); - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - - it("throws when scheduler.pauseJob returns false for the main job", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); - await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); - }); - - it("logs error but does not throw when geo pauseJob returns false", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); - - await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); - }); - }); - - // ── resumeJob ────────────────────────────────────────────────────────────── - - describe("resumeJob", () => { - it("resumes monitor and geo job when geo exists", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.resumeJob(makeMonitor()); - - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); - }); - - it("does not resume geo job when it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.resumeJob(makeMonitor()); - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - - it("throws when scheduler.resumeJob returns false for the main job", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); - await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); - }); - - it("logs error but does not throw when geo resumeJob returns false", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); - - await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); - }); - }); - - // ── updateJob / syncGeoJob ───────────────────────────────────────────────── - - describe("updateJob", () => { - it("updates monitor job with new interval", async () => { - const { queue, scheduler } = createQueue(); - const monitor = makeMonitor({ interval: 120000 }); - await queue.updateJob(monitor); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor })); - }); - - it("removes existing geo job when geoCheckEnabled is false", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" })); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("removes existing geo job when type does not support geo checks", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" })); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("updates existing geo job when geoCheckEnabled", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); - await queue.updateJob(monitor); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 })); - }); - - it("creates new geo job when enabled but no existing job", async () => { - const { queue, scheduler } = createQueue(); - const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); - await queue.updateJob(monitor); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" })); - }); - }); - - // ── shutdown ─────────────────────────────────────────────────────────────── - - describe("shutdown", () => { - it("stops the scheduler", async () => { - const { queue, scheduler } = createQueue(); - await queue.shutdown(); - expect(scheduler.stop).toHaveBeenCalled(); - }); - }); - - // ── getMetrics ───────────────────────────────────────────────────────────── - - describe("getMetrics", () => { - it("returns empty metrics when no jobs", async () => { - const { queue } = createQueue(); - const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] }); - }); - - it("maps the store's aggregate counters through", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getStats as jest.Mock).mockResolvedValue({ - jobs: 2, - activeJobs: 1, - failingJobs: 1, - totalRuns: 15, - totalFailures: 2, - jobsWithFailures: [], - }); - const metrics = await queue.getMetrics(); - expect(metrics).toMatchObject({ jobs: 2, activeJobs: 1, failingJobs: 1, totalRuns: 15, totalFailures: 2 }); - }); - - it("derives monitor fields for failing monitor jobs from job data", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getStats as jest.Mock).mockResolvedValue({ - jobs: 1, - activeJobs: 0, - failingJobs: 1, - totalRuns: 10, - totalFailures: 2, - jobsWithFailures: [{ id: "m1", data: { url: "http://a.com", type: "http" }, failedAt: 200, failCount: 2, failReason: "timeout" }], - }); - const metrics = await queue.getMetrics(); - expect(metrics.jobsWithFailures).toEqual([ - { monitorId: "m1", monitorUrl: "http://a.com", monitorType: "http", failedAt: 200, failCount: 2, failReason: "timeout" }, - ]); - }); - - it("leaves monitor fields null for non-monitor (cleanup) failures", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getStats as jest.Mock).mockResolvedValue({ - jobs: 1, - activeJobs: 0, - failingJobs: 1, - totalRuns: 1, - totalFailures: 1, - jobsWithFailures: [{ id: "cleanup-orphaned", data: undefined, failedAt: 100, failCount: 1, failReason: "boom" }], - }); - const metrics = await queue.getMetrics(); - expect(metrics.jobsWithFailures[0]).toEqual({ - monitorId: "cleanup-orphaned", - monitorUrl: null, - monitorType: null, - failedAt: 100, - failCount: 1, - failReason: "boom", - }); - }); - }); - - // ── getMetrics: worker registry ────────────────────────────────────────────── - - describe("getMetrics - worker registry", () => { - it("returns the alive workers from the registry", async () => { - const { queue, workersRepository } = createQueue(); - const alive = [ - { workerId: "host:1:aaa", mode: "primary", lastSeenAt: 1000 }, - { workerId: "host:2:bbb", mode: "worker", lastSeenAt: 2000 }, - ]; - (workersRepository.findRecent as jest.Mock).mockResolvedValue(alive); - - const metrics = await queue.getMetrics(); - - expect(workersRepository.findRecent).toHaveBeenCalledWith(30000); - expect(metrics.workers).toEqual(alive); - }); - }); - - // ── getJobs ──────────────────────────────────────────────────────────────── - - describe("getJobs", () => { - it("maps scheduler jobs to summaries", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { - id: "m1", - template: "monitor-job", - active: true, - repeat: 60000, - lockedAt: null, - runCount: 5, - failCount: 1, - lastError: "timeout", - lastStartedAt: 1000, - lastFinishedAt: 1100, - lastFailedAt: 900, - data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, - }, - ]); - const { jobs } = await queue.getJobs({}); - - expect(jobs).toHaveLength(1); - expect(jobs[0]).toEqual({ - monitorId: "m1", - monitorUrl: "http://a.com", - monitorType: "http", - monitorInterval: 60000, - monitorGeoInterval: 300000, - monitorActive: true, - active: true, - repeat: 60000, - lockedBy: null, - lockedUntil: null, - lockedAt: null, - runCount: 5, - failCount: 1, - failReason: "timeout", - lastRunAt: 1000, - lastFinishedAt: 1100, - lastRunTook: 100, - lastFailedAt: 900, - }); - }); - - it("returns null lastRunTook when job is locked", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { - id: "m1", - template: "monitor-job", - active: true, - lockedAt: 999, - runCount: 1, - lastStartedAt: 1000, - lastFinishedAt: 1100, - data: { url: "a", type: "http", interval: 60000 }, - }, - ]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].lastRunTook).toBeNull(); - }); - - it("leaves monitor fields null for non-monitor jobs", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, data: undefined }]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].monitorUrl).toBeNull(); - expect(jobs[0].monitorType).toBeNull(); - expect(jobs[0].monitorInterval).toBeNull(); - expect(jobs[0].monitorGeoInterval).toBeNull(); - expect(jobs[0].monitorActive).toBeNull(); - }); - - it("pushes pagination to the scheduler and returns its total count", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", template: "monitor-job", active: true, data: { url: "a", type: "http", interval: 60000 } }, - ]); - (scheduler.countJobs as jest.Mock).mockResolvedValue(42); - - const { jobs, count } = await queue.getJobs({ page: 2, rowsPerPage: 10 }); - - expect(scheduler.getJobs).toHaveBeenCalledWith({ skip: 20, limit: 10 }); - expect(count).toBe(42); - expect(jobs).toHaveLength(1); - }); - - it("requests all jobs when rowsPerPage is omitted", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.countJobs as jest.Mock).mockResolvedValue(7); - - const { count } = await queue.getJobs({}); - - expect(scheduler.getJobs).toHaveBeenCalledWith(undefined); - expect(count).toBe(7); - }); - }); - - // ── flushQueues ──────────────────────────────────────────────────────────── - - describe("flushQueues", () => { - it("stops, flushes, and reinitializes", async () => { - const { queue, scheduler } = createQueue(); - const result = await queue.flushQueues(); - expect(scheduler.stop).toHaveBeenCalled(); - expect(scheduler.flushJobs).toHaveBeenCalled(); - expect(result.success).toBe(true); - }); - - it("flushes before stopping, so the store is still connected for removeAll", async () => { - const { queue, scheduler } = createQueue(); - const order: string[] = []; - (scheduler.flushJobs as jest.Mock).mockImplementation(async () => { - order.push("flush"); - return true; - }); - (scheduler.stop as jest.Mock).mockImplementation(async () => { - order.push("stop"); - return true; - }); - await queue.flushQueues(); - expect(order).toEqual(["flush", "stop"]); - }); - - it("refuses to flush on a non-primary worker without touching the scheduler", async () => { - const { queue, scheduler } = createQueue({ queueMode: "worker" }); - const result = await queue.flushQueues(); - expect(result.success).toBe(false); - expect(scheduler.flushJobs).not.toHaveBeenCalled(); - expect(scheduler.stop).not.toHaveBeenCalled(); - }); - - it("returns false when flush fails", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); - const result = await queue.flushQueues(); - expect(result.success).toBe(false); - }); - }); - - // ── static create ────────────────────────────────────────────────────────── - - describe("static create", () => { - const envSettings = { dbConnectionString: "mongodb://localhost:27017/test_db" } as any; - - it("creates a MongoStore + Scheduler and inits in primary mode", async () => { - const logger = createMockLogger(); - const helper = createQueueHelper(); - const monitorsRepository = createMonitorsRepo(); - const workersRepository = createWorkersRepo(); - - const instance = await LessSimpleQueue.create( - logger as any, - helper as any, - monitorsRepository as any, - workersRepository as any, - envSettings, - "primary" - ); - - expect(MockMongoStore).toHaveBeenCalledWith(expect.objectContaining({ url: "mongodb://localhost:27017/test_db" })); - expect(MockScheduler).toHaveBeenCalled(); - expect(instance).toBeInstanceOf(LessSimpleQueue); - expect(mockSchedulerInstance.start).toHaveBeenCalled(); - expect(monitorsRepository.findAll).toHaveBeenCalled(); - }); - - it("inits in worker mode without seeding monitors", async () => { - const logger = createMockLogger(); - const helper = createQueueHelper(); - const monitorsRepository = createMonitorsRepo(); - const workersRepository = createWorkersRepo(); - - const instance = await LessSimpleQueue.create( - logger as any, - helper as any, - monitorsRepository as any, - workersRepository as any, - envSettings, - "worker" - ); - - expect(instance).toBeInstanceOf(LessSimpleQueue); - expect(monitorsRepository.findAll).not.toHaveBeenCalled(); - }); - }); - - // ── worker heartbeat / deregistration ──────────────────────────────────────── - - describe("worker heartbeat", () => { - it("writes a heartbeat on init with the workerId and mode (primary)", async () => { - const { queue, workersRepository } = createQueue(); - await queue.init("primary"); - expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "primary"); - }); - - it("writes a heartbeat on init with the workerId and mode (worker)", async () => { - const { queue, workersRepository } = createQueue(); - await queue.init("worker"); - expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); - }); - - it("beats again on each scheduler:heartbeat event", async () => { - const { queue, scheduler, workersRepository } = createQueue(); - await queue.init("worker"); - (workersRepository.upsert as jest.Mock).mockClear(); - - scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); - scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); - - expect(workersRepository.upsert).toHaveBeenCalledTimes(2); - expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); - }); - - it("stops beating after shutdown", async () => { - const { queue, scheduler, workersRepository } = createQueue(); - await queue.init("worker"); - await queue.shutdown(); - (workersRepository.upsert as jest.Mock).mockClear(); - - scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); - - expect(workersRepository.upsert).not.toHaveBeenCalled(); - }); - - it("deregisters the worker and stops the scheduler on shutdown", async () => { - const { queue, scheduler, workersRepository } = createQueue(); - await queue.init("worker"); - await queue.shutdown(); - expect(workersRepository.deleteById).toHaveBeenCalledWith("host-1:1234:abcd"); - expect(scheduler.stop).toHaveBeenCalled(); - }); - }); -}); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 376b8f20d..9547eaac8 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { MaintenanceWindowService } from "../../../src/service/business/maintenanceWindowService.ts"; -import type { IMaintenanceWindowsRepository } from "../../../src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts"; -import type { IMonitorsRepository } from "../../../src/repositories/monitors/IMonitorsRepository.ts"; -import type { MaintenanceWindow } from "../../../src/types/index.ts"; +import { MaintenanceWindowService } from "../../../src/domain/maintenance-windows/maintenance-window.service.ts"; +import type { IMaintenanceWindowsRepository } from "../../../src/domain/maintenance-windows/maintenance-window.repository.interface.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts"; +import type { IJobScheduler } from "../../../src/worker/worker.interface.ts"; +import type { MaintenanceWindow } from "../../../src/domain/maintenance-windows/maintenance-window.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -27,14 +29,28 @@ const createMonitorsRepo = () => updateByIds: jest.fn().mockResolvedValue(0), }) as unknown as jest.Mocked; +const createJobsRepo = () => + ({ + markMonitorsDue: jest.fn().mockResolvedValue(0), + }) as unknown as jest.Mocked; + +const createWorker = () => + ({ + wake: jest.fn(), + }) as unknown as jest.Mocked; + const createService = (overrides?: { monitorsRepository?: ReturnType; maintenanceWindowsRepository?: ReturnType; + jobsRepository?: ReturnType; + worker?: ReturnType; }) => { const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); const maintenanceWindowsRepository = overrides?.maintenanceWindowsRepository ?? createMaintenanceWindowsRepo(); - const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository }); - return { service, monitorsRepository, maintenanceWindowsRepository }; + const jobsRepository = overrides?.jobsRepository ?? createJobsRepo(); + const worker = overrides?.worker ?? createWorker(); + const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository, jobsRepository, scheduler: worker }); + return { service, monitorsRepository, maintenanceWindowsRepository, jobsRepository, worker }; }; const makeWindow = (overrides?: Partial): MaintenanceWindow => ({ @@ -267,12 +283,27 @@ describe("MaintenanceWindowService", () => { it("flips covered monitors to initializing when deleting an active window", async () => { const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); - const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + const { service, monitorsRepository, jobsRepository, worker } = createService({ maintenanceWindowsRepository }); await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]); + // Monitors leaving maintenance are re-armed to run soon instead of waiting out the interval + expect(jobsRepository.markMonitorsDue).toHaveBeenCalledWith(["mon-1", "mon-2"], expect.any(Number)); + // ...and the idle loops are woken so the re-armed jobs run promptly + expect(worker.wake).toHaveBeenCalledWith("check"); + expect(worker.wake).toHaveBeenCalledWith("geo-check"); + }); + + it("does not wake the loops when no monitor leaves maintenance", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeWindow({ active: false, monitorIds: ["mon-1"] })); + const { service, worker } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(worker.wake).not.toHaveBeenCalled(); }); it("excludes the deleted window from the overlap check", async () => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 5e73ab77b..65d6923a6 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { MonitorService } from "../../../src/service/business/monitorService.ts"; -import type { - IChecksRepository, - IGeoChecksRepository, - IIncidentsRepository, - IMonitorStatsRepository, - IMonitorsRepository, - IStatusPagesRepository, -} from "../../../src/repositories/index.ts"; +import { MonitorService } from "../../../src/domain/monitors/monitor.service.ts"; +import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { IGeoChecksRepository } from "../../../src/domain/geo-checks/geo-check.repository.interface.ts"; +import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; +import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; const createMonitorsRepositoryMock = () => @@ -16,6 +14,7 @@ const createMonitorsRepositoryMock = () => createMonitors: jest.fn(), findById: jest.fn(), findByTeamId: jest.fn(), + findByTeamIdWithStats: jest.fn(), findByIds: jest.fn(), findMonitorCountByTeamIdAndType: jest.fn(), findMonitorsSummaryByTeamId: jest.fn(), @@ -99,7 +98,7 @@ const createService = ( const jobQueue = overrides.jobQueue ?? createJobQueueMock(); const logger = overrides.logger ?? createMockLogger(); const service = new MonitorService({ - jobQueue: jobQueue as any, + scheduler: jobQueue as any, logger: logger as any, games: (overrides.games ?? { cs2: { name: "Counter-Strike 2" } }) as any, monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), @@ -562,7 +561,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1, upMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ makeMonitor({ recentChecks: [ { responseTime: 10, status: true, message: "OK" }, @@ -583,7 +582,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue(null); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(null); + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue(null); const { service } = createService({ monitorsRepository }); const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); @@ -596,7 +595,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ makeMonitor({ type: "hardware", recentChecks: [ @@ -617,7 +616,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ makeMonitor({ type: "hardware", recentChecks: [ @@ -637,7 +636,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ makeMonitor({ type: "http", recentChecks: [ @@ -657,7 +656,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 2 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ makeMonitor({ id: "m1", type: "http", @@ -689,7 +688,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [] })]); + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [] })]); const { service } = createService({ monitorsRepository }); const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); @@ -701,7 +700,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: undefined })]); + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: undefined })]); const { service } = createService({ monitorsRepository }); const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); @@ -713,7 +712,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 0 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([]); const { service } = createService({ monitorsRepository }); await service.getMonitorsWithChecksByTeamId({ @@ -727,7 +726,7 @@ describe("MonitorService", () => { order: "asc", }); - expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith(TEAM_ID, { + expect(monitorsRepository.findByTeamIdWithStats).toHaveBeenCalledWith(TEAM_ID, { limit: 10, type: "http", page: 1, @@ -1536,7 +1535,7 @@ describe("MonitorService", () => { const logger = createMockLogger(); const service = new MonitorService({ - jobQueue: jobQueue as any, + scheduler: jobQueue as any, logger: logger as any, games: {} as any, monitorsRepository, diff --git a/server/test/unit/services/monitorStatusPolicy.test.ts b/server/test/unit/services/monitorStatusPolicy.test.ts new file mode 100644 index 000000000..7b340a3e9 --- /dev/null +++ b/server/test/unit/services/monitorStatusPolicy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "@jest/globals"; +import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts"; +import type { StatusChangeResult } from "../../../src/types/network.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeStatusChange = (overrides: Partial & { status?: string }): StatusChangeResult => { + const { status = "up", ...rest } = overrides; + return { + monitor: { id: "m1", status } as any, + statusChanged: false, + prevStatus: "up", + code: 200, + timestamp: 0, + ...rest, + } as StatusChangeResult; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("MonitorStatusPolicy", () => { + const policy = new MonitorStatusPolicy(); + + it("does nothing when statusChanged is false", () => { + const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" })); + expect(decision).toEqual({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }); + }); + + it("creates incident and notifies when monitor goes down", () => { + const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", code: 500 })); + expect(decision).toMatchObject({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }); + }); + + it("creates incident and notifies when monitor is breached", () => { + const decision = policy.evaluate(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" })); + expect(decision).toMatchObject({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "threshold_breach", + notificationReason: "threshold_breach", + }); + }); + + it("resolves incident when monitor recovers from down", () => { + const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" })); + expect(decision).toMatchObject({ + shouldResolveIncident: true, + shouldSendNotification: true, + shouldCreateIncident: false, + notificationReason: "status_change", + }); + }); + + it("resolves incident when monitor recovers from breached", () => { + const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" })); + expect(decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true }); + }); + + it("does not create or resolve for unhandled status transitions", () => { + const decision = policy.evaluate(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" })); + expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }); + }); +}); diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts index c9f496166..268b52e3a 100644 --- a/server/test/unit/services/networkService.test.ts +++ b/server/test/unit/services/networkService.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { NetworkService } from "../../../src/service/infrastructure/networkService.ts"; +import { NetworkService } from "../../../src/service/networkService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts"; -import type { Monitor, MonitorType } from "../../../src/types/index.ts"; +import { NETWORK_ERROR } from "../../../src/service/network/utils.ts"; +import type { IStatusProvider } from "../../../src/service/network/IStatusProvider.ts"; +import type { Monitor, MonitorType } from "../../../src/domain/monitors/monitor.types.ts"; import type { AxiosStatic } from "axios"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts index 4403a8bb3..6e1c4f4dc 100644 --- a/server/test/unit/services/notificationMessageBuilder.test.ts +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; -import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; -import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; +import { NotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/network.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index 9527fd3b5..8ba8b34aa 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts"; +import { NotificationsService } from "../../../src/domain/notifications/notification.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts"; +import type { Notification } from "../../../src/domain/notifications/notification.type.ts"; +import type { MonitorStatusResponse } from "../../../src/types/network.ts"; +import type { MonitorActionDecision } from "../../../src/worker/worker.helper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/reactorDispatcher.test.ts b/server/test/unit/services/reactorDispatcher.test.ts new file mode 100644 index 000000000..0bcc90526 --- /dev/null +++ b/server/test/unit/services/reactorDispatcher.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ReactorDispatcher } from "../../../src/worker/reactors/reactor.dispatcher.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const evaluation = { monitor: { id: "m1" } } as any; + +const makeReactor = (name: string, blocking: boolean, impl?: () => Promise) => ({ + name, + blocking, + react: jest.fn(impl ?? (() => Promise.resolve())), +}); + +describe("ReactorDispatcher", () => { + it("runs every reactor with the evaluation", async () => { + const a = makeReactor("a", true); + const b = makeReactor("b", false); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [a, b] as any); + + await dispatcher.dispatch(evaluation); + + expect(a.react).toHaveBeenCalledWith(evaluation); + expect(b.react).toHaveBeenCalledWith(evaluation); + }); + + it("awaits blocking reactors before resolving", async () => { + let done = false; + const blocking = makeReactor( + "incident", + true, + () => + new Promise((r) => + setTimeout(() => { + done = true; + r(); + }, 10) + ) + ); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [blocking] as any); + + await dispatcher.dispatch(evaluation); + + expect(done).toBe(true); + }); + + it("does not await non-blocking reactors", async () => { + let done = false; + const nonBlocking = makeReactor( + "notification", + false, + () => + new Promise((r) => + setTimeout(() => { + done = true; + r(); + }, 20) + ) + ); + const dispatcher = new ReactorDispatcher(createMockLogger() as any, [nonBlocking] as any); + + await dispatcher.dispatch(evaluation); + + expect(done).toBe(false); // resolved before the slow fire-and-forget reactor finished + }); + + it("isolates a throwing blocking reactor and still runs later reactors", async () => { + const logger = createMockLogger(); + const bad = makeReactor("incident", true, () => Promise.reject(new Error("db error"))); + const good = makeReactor("after", true); + const dispatcher = new ReactorDispatcher(logger as any, [bad, good] as any); + + await expect(dispatcher.dispatch(evaluation)).resolves.toBeUndefined(); + + expect(good.react).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); + }); + + it("isolates a rejecting non-blocking reactor (fire-and-forget)", async () => { + const logger = createMockLogger(); + const bad = makeReactor("notification", false, () => Promise.reject(new Error("smtp down"))); + const dispatcher = new ReactorDispatcher(logger as any, [bad] as any); + + await dispatcher.dispatch(evaluation); + await new Promise((r) => setTimeout(r, 10)); // let the detached catch run + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); + }); +}); diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts index da75d3ee5..af58b5705 100644 --- a/server/test/unit/services/settingsService.test.ts +++ b/server/test/unit/services/settingsService.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { SettingsService } from "../../../src/service/system/settingsService.ts"; -import type { ISettingsRepository } from "../../../src/repositories/settings/ISettingsRepository.ts"; -import type { ValidatedEnv } from "../../../src/validation/envValidation.ts"; -import type { Settings } from "../../../src/types/index.ts"; +import { SettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; +import type { ISettingsRepository } from "../../../src/domain/app-settings/app-settings-repository.interface.ts"; +import type { ValidatedEnv } from "../../../src/config/envValidation.ts"; +import type { Settings } from "../../../src/domain/app-settings/app-settings.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 2deac9897..b2c2cdcee 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { StatusPageService } from "../../../src/service/business/statusPageService.ts"; -import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts"; -import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; -import type { StatusPage } from "../../../src/types/index.ts"; -import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/index.ts"; +import { StatusPageService } from "../../../src/domain/status-pages/status-page.service.ts"; +import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; +import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -26,20 +26,22 @@ const createRepo = () => ({ create: jest.fn().mockResolvedValue(makeStatusPage()), findByUrl: jest.fn().mockResolvedValue(makeStatusPage()), + findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]), updateById: jest.fn().mockResolvedValue(makeStatusPage()), deleteById: jest.fn().mockResolvedValue(makeStatusPage()), removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), }) as unknown as jest.Mocked; -const createSettingsService = (themesEnabled: boolean) => +const createSettingsService = (themesEnabled: boolean, clientHost = "http://localhost:5173") => ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + getSettings: jest.fn().mockReturnValue({ clientHost }), }) as unknown as jest.Mocked; -const createService = (themesEnabled = true) => { +const createService = (themesEnabled = true, clientHost = "http://localhost:5173") => { const repo = createRepo(); - const settingsService = createSettingsService(themesEnabled); + const settingsService = createSettingsService(themesEnabled, clientHost); const service = new StatusPageService(repo, settingsService); return { service, repo, settingsService }; }; @@ -99,6 +101,50 @@ describe("StatusPageService", () => { }); }); + describe("getStatusPageByCustomDomain", () => { + it("delegates to repository", async () => { + const { service, repo } = createService(true); + + const result = await service.getStatusPageByCustomDomain("status.example.com"); + + expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com"); + expect(result).toEqual(makeStatusPage()); + }); + }); + + describe("createStatusPage custom domain validation", () => { + it("rejects custom domains that match the Checkmate instance host", async () => { + const { service } = createService(true, "https://checkmate.example.com"); + + await expect( + service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "checkmate.example.com", + }) + ).rejects.toMatchObject({ + status: 400, + }); + }); + + it("normalizes custom domains before create", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { + customDomain: "https://Status.Example.COM", + }); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, expect.objectContaining({ customDomain: "status.example.com" })); + }); + + it("allows multiple status pages without a custom domain", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "First" }); + await service.createStatusPage("user-1", "team-1", undefined, { companyName: "Second" }); + + expect(repo.create).toHaveBeenCalledTimes(2); + }); + }); + describe("getStatusPagesByTeamId", () => { it("delegates to repository", async () => { const { service, repo } = createService(true); diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index 75323943b..5712de301 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -1,23 +1,21 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { StatusService } from "../../../src/service/infrastructure/statusService.ts"; +import { StatusService } from "../../../src/service/statusService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import { MAX_RECENT_CHECKS } from "../../../src/types/index.ts"; -import type { Monitor, MonitorStatus, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts"; -import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts"; -import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts"; +import { MAX_RECENT_CHECKS } from "../../../src/domain/monitors/monitor.types.ts"; +import type { Monitor, MonitorStatus } from "../../../src/domain/monitors/monitor.types.ts"; +import type { Check } from "../../../src/domain/checks/check.type.ts"; +import type { MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/network.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── -const createBuffer = (): jest.Mocked> => ({ - addToBuffer: jest.fn(), -}); - const createMonitorsRepo = () => { const findById = jest.fn(); const updateStatusWindowAndChecks = jest .fn() .mockImplementation( - ( + async ( _id: unknown, _tid: unknown, status: boolean, @@ -26,27 +24,23 @@ const createMonitorsRepo = () => { maxRecentChecks: number, statusPatch?: Partial ) => { - // Grab the monitor that findById was configured to resolve with - const monitor = findById.mock.results.at(-1)?.value; - if (monitor && typeof monitor.then === "function") { - return monitor.then((m: any) => { - m.statusWindow = m.statusWindow || []; - m.statusWindow.push(status); - while (m.statusWindow.length > windowSize) { - m.statusWindow.shift(); - } - m.recentChecks = m.recentChecks || []; - m.recentChecks.push(checkSnapshot); - while (m.recentChecks.length > maxRecentChecks) { - m.recentChecks.shift(); - } - if (statusPatch) { - Object.assign(m, statusPatch); - } - return { ...m }; - }); + // updateMonitorStatus no longer reads the monitor itself — tests seed it via + // findById.mockResolvedValue, so the mock resolves it here as the post-write monitor. + const m: any = await findById(); + m.statusWindow = m.statusWindow || []; + m.statusWindow.push(status); + while (m.statusWindow.length > windowSize) { + m.statusWindow.shift(); } - return Promise.reject(new Error("findById not mocked")); + m.recentChecks = m.recentChecks || []; + m.recentChecks.push(checkSnapshot); + while (m.recentChecks.length > maxRecentChecks) { + m.recentChecks.shift(); + } + if (statusPatch) { + Object.assign(m, statusPatch); + } + return { ...m }; } ); return { @@ -63,23 +57,17 @@ const createMonitorStatsRepo = () => updateByMonitorId: jest.fn(), }) as unknown as jest.Mocked; -const createChecksRepo = () => ({}) as unknown as jest.Mocked; - const createService = (overrides?: { logger?: ReturnType; - buffer?: ReturnType; monitorsRepository?: ReturnType; monitorStatsRepository?: ReturnType; - checksRepository?: ReturnType; }) => { const logger = overrides?.logger ?? createMockLogger(); - const buffer = overrides?.buffer ?? createBuffer(); const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo(); - const checksRepository = overrides?.checksRepository ?? createChecksRepo(); - const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository); - return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository }; + const service = new StatusService(logger as any, monitorsRepository, monitorStatsRepository); + return { service, logger, monitorsRepository, monitorStatsRepository }; }; const makeMonitor = (overrides?: Partial): Monitor => @@ -260,19 +248,31 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); expect(result.statusChanged).toBe(false); expect(result.prevStatus).toBe("up"); }); + it("uses the prefetched monitor and skips findById when one is passed", async () => { + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); + + expect(monitorsRepository.findById).not.toHaveBeenCalled(); + expect(result.prevStatus).toBe("up"); + expect(result.monitor).toBe(monitor); + }); + it("pushes to statusWindow and trims to statusWindowSize", async () => { const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 }); const { service, monitorsRepository } = createService(); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); // Atomic push should have been called with the correct status and window size expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith( @@ -293,7 +293,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" })); + await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }), monitor); expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS); expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" })); @@ -311,7 +311,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("down"); @@ -329,7 +329,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("up"); @@ -346,7 +346,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("up"); @@ -366,7 +366,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("up"); @@ -383,7 +383,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("down"); @@ -395,7 +395,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); expect(monitor.statusWindow).toEqual([true]); }); @@ -406,23 +406,25 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); expect(monitor.recentChecks).toHaveLength(1); }); it("throws AppError when repository throws", async () => { + const monitor = makeMonitor(); const { service, monitorsRepository } = createService(); - (monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error")); + (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue(new Error("db error")); - await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor"); + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor)).rejects.toThrow("Failed to update monitor"); }); it("throws AppError with 'Unknown error' for non-Error exceptions", async () => { + const monitor = makeMonitor(); const { service, monitorsRepository } = createService(); - (monitorsRepository.findById as jest.Mock).mockRejectedValue("string error"); + (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mockRejectedValue("string error"); - await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error"); + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor)).rejects.toThrow("Unknown error"); }); it("keeps status 'down' on a single successful check that leaves failure rate at threshold (regression: #3438 mirror)", async () => { @@ -440,7 +442,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("down"); @@ -458,7 +460,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("down"); @@ -480,7 +482,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); expect(result.monitor.status).toBe("up"); }); @@ -498,7 +500,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.monitor.status).toBe("down"); }); @@ -514,7 +516,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("up"); @@ -531,7 +533,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("down"); @@ -546,13 +548,62 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("down"); expect(result.prevStatus).toBe("initializing"); }); + it("leaves 'maintenance' for 'up' on a passing check after the window closes (empty statusWindow)", async () => { + // On entry to a maintenance window the producer clears statusWindow, so the first + // check after the window closes sees an empty window and lands in the warmup + // early-return branch. A passing check must move the monitor out of 'maintenance' + // to 'up' without flagging a status change (no incident). + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("leaves 'maintenance' for 'down' on a failing check after the window closes (empty statusWindow)", async () => { + // Mirror of the recovery case: a failing first check after the window closes must + // flip the monitor straight to 'down' and surface statusChanged so an incident opens, + // rather than leave it stuck in 'maintenance'. + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("unsticks from 'maintenance' to 'up' on a passing check even with a full window (regression: stuck in maintenance)", async () => { + // Guards the up-front override directly: even if a full window survives (e.g. the + // entry-clear is bypassed), computeReachability only transitions out to 'up' from + // 'down', so without the override the monitor would stay stuck in 'maintenance'. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "maintenance", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); + + expect(result.monitor.status).toBe("up"); + }); + it("does not flip status on subsequent checks during warmup once 'initializing' has been left (regression: down→up incident resolves once window fills)", async () => { // After a 'initializing'→'down' transition on check #1, monitor.status is now 'down' // and statusWindow has one false. Subsequent up checks during warmup must leave @@ -563,7 +614,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); expect(result.statusChanged).toBe(false); expect(result.monitor.status).toBe("down"); @@ -576,7 +627,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("stats db down")); - const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck(), monitor); expect(result.monitor.status).toBe("up"); expect(logger.warn).toHaveBeenCalledWith( @@ -594,7 +645,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck()); + const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck(), monitor); expect(result.code).toBe(201); expect(result.timestamp).toBeGreaterThan(0); @@ -635,7 +686,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.cpu).toBe(true); expect(result.thresholdBreaches?.memory).toBe(false); @@ -652,7 +703,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.memory).toBe(true); const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; @@ -669,7 +720,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.disk).toBe(true); const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; @@ -686,7 +737,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.temp).toBe(true); const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; @@ -708,7 +759,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, } as any); - await service.updateMonitorStatus(response, makeCheck()); + await service.updateMonitorStatus(response, makeCheck(), monitor); const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; expect(patch.cpuAlertCounter).toBe(5); @@ -727,7 +778,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.monitor.status).toBe("breached"); expect(result.statusChanged).toBe(false); @@ -742,7 +793,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("up"); @@ -765,7 +816,7 @@ describe("StatusService", () => { status: false, data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck({ status: false })); + const result = await service.updateMonitorStatus(response, makeCheck({ status: false }), monitor); expect(result.monitor.status).toBe("down"); }); @@ -777,7 +828,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.cpu).toBe(false); }); @@ -789,7 +840,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.memory).toBe(false); }); @@ -803,7 +854,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.temp).toBe(false); }); @@ -817,7 +868,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.temp).toBe(false); }); @@ -829,7 +880,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse(undefined as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches).toBeUndefined(); }); @@ -841,7 +892,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({} as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches).toBeUndefined(); }); @@ -856,7 +907,7 @@ describe("StatusService", () => { (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); - const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck(), monitor); expect(result.thresholdBreaches).toBeUndefined(); }); @@ -870,7 +921,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.disk).toBe(false); }); @@ -884,7 +935,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.disk).toBe(false); }); @@ -898,7 +949,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.cpu).toBe(false); expect(result.thresholdBreaches?.temp).toBe(false); @@ -913,7 +964,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.memory).toBe(false); }); @@ -927,7 +978,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); expect(result.thresholdBreaches?.disk).toBe(false); }); @@ -941,7 +992,7 @@ describe("StatusService", () => { (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); - const result = await service.updateMonitorStatus(response, makeCheck()); + const result = await service.updateMonitorStatus(response, makeCheck(), monitor); const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; expect(patch.cpuAlertCounter).toBe(2); @@ -967,7 +1018,7 @@ describe("StatusService", () => { status: false, data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, } as any); - const result = await service.updateMonitorStatus(response, makeCheck({ status: false })); + const result = await service.updateMonitorStatus(response, makeCheck({ status: false }), monitor); expect(result.statusChanged).toBe(true); expect(result.monitor.status).toBe("down"); @@ -987,7 +1038,7 @@ describe("StatusService", () => { const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} }, } as any); - await service.updateMonitorStatus(response, makeCheck()); + await service.updateMonitorStatus(response, makeCheck(), monitor); expect(monitor.cpuAlertCounter).toBe(0); expect(monitor.memoryAlertCounter).toBe(0); diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts deleted file mode 100644 index b6da507ce..000000000 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ /dev/null @@ -1,712 +0,0 @@ -import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { Monitor } from "../../../src/types/monitor.ts"; - -// ── Mock Scheduler ─────────────────────────────────────────────────────────── - -const createScheduler = () => { - const listeners: Record = {}; - return { - on: jest.fn((event: string, cb: Function) => { - listeners[event] = listeners[event] || []; - listeners[event].push(cb); - }), - emit: (event: string, ...args: unknown[]) => { - (listeners[event] || []).forEach((cb) => cb(...args)); - }, - start: jest.fn(), - stop: jest.fn().mockResolvedValue(true), - addTemplate: jest.fn(), - addJob: jest.fn(), - removeJob: jest.fn(), - getJob: jest.fn().mockResolvedValue(null), - getJobs: jest.fn().mockResolvedValue([]), - pauseJob: jest.fn().mockResolvedValue(true), - resumeJob: jest.fn().mockResolvedValue(true), - updateJob: jest.fn(), - flushJobs: jest.fn().mockResolvedValue(true), - }; -}; - -const mockSchedulerInstance = createScheduler(); -const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance); - -jest.unstable_mockModule("super-simple-scheduler", () => ({ - default: MockScheduler, -})); - -const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/SuperSimpleQueue.ts"); - -const createQueueHelper = () => ({ - getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()), - getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()), -}); - -const createMonitorsRepo = () => ({ - findAll: jest.fn().mockResolvedValue([]), -}); - -const createQueue = (overrides?: Record) => { - const logger = createMockLogger(); - const helper = createQueueHelper(); - const monitorsRepository = createMonitorsRepo(); - const scheduler = createScheduler(); - - const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides }; - - const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any); - return { queue, ...defaults }; -}; - -const makeMonitor = (overrides?: Partial): Monitor => - ({ - id: "mon-1", - teamId: "team-1", - type: "http", - interval: 60000, - isActive: true, - geoCheckEnabled: false, - geoCheckInterval: 300000, - geoCheckLocations: [], - ...overrides, - }) as Monitor; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe("SuperSimpleQueue", () => { - describe("serviceName", () => { - it("returns JobQueue", () => { - const { queue } = createQueue(); - expect(queue.serviceName).toBe("JobQueue"); - }); - }); - - // ── init ───────────────────────────────────────────────────────────────── - - describe("init", () => { - it("registers listeners, starts scheduler, adds templates and system jobs", async () => { - const { queue, scheduler, helper } = createQueue(); - - const result = await queue.init(); - - expect(result).toBe(true); - expect(scheduler.start).toHaveBeenCalled(); - expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); - expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); - }); - - it("schedules existing monitors with staggered offsets", async () => { - jest.useFakeTimers(); - const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; - const { queue, scheduler, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); - - await queue.init(); - jest.runAllTimers(); - - // Each monitor gets addJob called (once for the monitor itself via addJob method) - // The init schedules via setTimeout → addJob, plus the two system jobs - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" })); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" })); - jest.useRealTimers(); - }); - - it("returns true when findAll returns null", async () => { - const { queue, monitorsRepository, scheduler } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockResolvedValue(null); - - const result = await queue.init(); - - expect(result).toBe(true); - // System jobs should not be added when monitors is null (early return) - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); - }); - - it("returns false and logs error on failure", async () => { - const { queue, logger, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down")); - - const result = await queue.init(); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") })); - }); - - it("logs error with String(error) for non-Error exceptions", async () => { - const { queue, logger, monitorsRepository } = createQueue(); - (monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error"); - - const result = await queue.init(); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") })); - }); - }); - - // ── registerListeners ──────────────────────────────────────────────────── - - describe("registerListeners (via init)", () => { - it("logs on scheduler:start", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("scheduler:start"); - expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); - }); - - it("logs on scheduler:stop", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("scheduler:stop"); - expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); - }); - - it("logs on scheduler:error with Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("scheduler:error", new Error("boom")); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" })); - }); - - it("logs on scheduler:error with non-Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("scheduler:error", "string error"); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined })); - }); - - it("logs on job:abort", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:abort", { id: "m1" }, "timeout"); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" })); - }); - - it("logs on job:attempt", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:attempt", { id: "m1" }, 2); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" })); - }); - - it("logs on job:complete", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:complete", { id: "m1" }); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" })); - }); - - it("logs on job:exhausted with Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up")); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" })); - }); - - it("logs on job:exhausted with non-Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:exhausted", { id: "m1" }, "gave up"); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined })); - }); - - it("logs on job:fail with Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" })); - }); - - it("logs on job:fail with non-Error", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:fail", { id: "m1" }, "oops", 1); - expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined })); - }); - - it("logs on job:start", async () => { - const { queue, scheduler, logger } = createQueue(); - await queue.init(); - scheduler.emit("job:start", { id: "m1" }); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" })); - }); - }); - - // ── addJob ─────────────────────────────────────────────────────────────── - - describe("addJob", () => { - it("adds monitor job to scheduler", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor()); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })); - }); - - it("adds geo check job when geoCheckEnabled and type supports it", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 })); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })); - }); - - it("skips geo job when type does not support geo checks", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" })); - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); - }); - - it("skips geo job when geoCheckEnabled is false", async () => { - const { queue, scheduler } = createQueue(); - await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" })); - expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); - }); - }); - - // ── deleteJob ──────────────────────────────────────────────────────────── - - describe("deleteJob", () => { - it("removes monitor job", async () => { - const { queue, scheduler } = createQueue(); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1"); - }); - - it("removes geo job if it exists", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("does not remove geo job if it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.deleteJob(makeMonitor()); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - }); - - // ── pauseJob ───────────────────────────────────────────────────────────── - - describe("pauseJob", () => { - it("pauses monitor and pauses geo job when geo exists", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.pauseJob(makeMonitor()); - - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); - }); - - it("refreshes scheduler data with current monitor on pause", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - const monitor = makeMonitor({ isActive: false, status: "paused" }); - - await queue.pauseJob(monitor); - - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); - }); - - it("does not pause geo job when it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.pauseJob(makeMonitor()); - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - - it("does not refresh geo data when geo job does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.pauseJob(makeMonitor()); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); - expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); - }); - - it("throws when scheduler.pauseJob returns false for the main job", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); - await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); - }); - - it("does not refresh data when main pauseJob throws", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); - await expect(queue.pauseJob(makeMonitor())).rejects.toThrow(); - expect(scheduler.updateJob).not.toHaveBeenCalled(); - }); - - it("logs error but does not throw when geo pauseJob returns false", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); - - await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); - }); - }); - - // ── resumeJob ──────────────────────────────────────────────────────────── - - describe("resumeJob", () => { - it("resumes monitor and geo job when geo exists", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.resumeJob(makeMonitor()); - - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); - expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); - }); - - it("refreshes scheduler data with current monitor on resume", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - const monitor = makeMonitor({ isActive: true, status: "initializing" }); - - await queue.resumeJob(monitor); - - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); - }); - - it("does not resume geo job when it does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.resumeJob(makeMonitor()); - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - - it("does not refresh geo data when geo job does not exist", async () => { - const { queue, scheduler } = createQueue(); - await queue.resumeJob(makeMonitor()); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); - expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); - }); - - it("throws when scheduler.resumeJob returns false for the main job", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); - await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); - }); - - it("does not refresh data when main resumeJob throws", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); - await expect(queue.resumeJob(makeMonitor())).rejects.toThrow(); - expect(scheduler.updateJob).not.toHaveBeenCalled(); - }); - - it("logs error but does not throw when geo resumeJob returns false", async () => { - const { queue, scheduler, logger } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); - - await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); - }); - - it("preserves geo job through a pause/resume cycle", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - - await queue.pauseJob(makeMonitor()); - await queue.resumeJob(makeMonitor()); - - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); - expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); - expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - }); - - // ── updateJob ──────────────────────────────────────────────────────────── - - describe("updateJob", () => { - it("updates monitor job and syncs geo job", async () => { - const { queue, scheduler } = createQueue(); - const monitor = makeMonitor({ interval: 120000 }); - await queue.updateJob(monitor); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor })); - }); - }); - - // ── syncGeoJob (via updateJob) ─────────────────────────────────────────── - - describe("syncGeoJob (via updateJob)", () => { - it("removes existing geo job when geoCheckEnabled is false", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" })); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("removes existing geo job when type does not support geo checks", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" })); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); - }); - - it("does not remove when geo disabled and no existing geo job", async () => { - const { queue, scheduler } = createQueue(); - await queue.updateJob(makeMonitor({ geoCheckEnabled: false })); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); - }); - - it("updates existing geo job when geoCheckEnabled", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); - const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); - await queue.updateJob(monitor); - expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 })); - }); - - it("creates new geo job when enabled but no existing job", async () => { - const { queue, scheduler } = createQueue(); - const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); - await queue.updateJob(monitor); - expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" })); - }); - }); - - // ── shutdown ────────────────────────────────────────────────────────────── - - describe("shutdown", () => { - it("stops the scheduler", async () => { - const { queue, scheduler } = createQueue(); - await queue.shutdown(); - expect(scheduler.stop).toHaveBeenCalled(); - }); - }); - - // ── getMetrics ─────────────────────────────────────────────────────────── - - describe("getMetrics", () => { - it("returns empty job metrics plus this process as the sole worker when no jobs", async () => { - const { queue } = createQueue(); - const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ - jobs: 0, - activeJobs: 0, - failingJobs: 0, - jobsWithFailures: [], - totalRuns: 0, - totalFailures: 0, - workers: [ - { - // hostname:pid:uuid, matching less-simple-scheduler's workerId format - workerId: expect.stringMatching(/^.+:\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), - mode: "primary", - lastSeenAt: expect.any(Number), - }, - ], - }); - }); - - it("aggregates metrics from jobs", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } }, - { id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } }, - ]); - const metrics = await queue.getMetrics(); - - expect(metrics.jobs).toBe(2); - expect(metrics.totalRuns).toBe(15); - expect(metrics.totalFailures).toBe(2); - expect(metrics.activeJobs).toBe(1); - expect(metrics.failingJobs).toBe(1); - expect(metrics.jobsWithFailures).toHaveLength(1); - expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" })); - }); - - it("handles jobs with undefined fields", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]); - const metrics = await queue.getMetrics(); - expect(metrics.jobs).toBe(1); - expect(metrics.totalRuns).toBe(0); - }); - - it("handles failing job with null data", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" }, - ]); - const metrics = await queue.getMetrics(); - expect(metrics.jobsWithFailures).toHaveLength(1); - expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull(); - expect(metrics.jobsWithFailures[0].monitorType).toBeNull(); - expect(metrics.jobsWithFailures[0].failReason).toBe("timeout"); - }); - - it("handles failing job with undefined lastFailedAt and lastFailReason", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]); - const metrics = await queue.getMetrics(); - expect(metrics.jobsWithFailures[0].failedAt).toBeNull(); - expect(metrics.jobsWithFailures[0].failReason).toBeNull(); - }); - - it("does not count as failing when lastFailedAt < lastRunAt", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } }, - ]); - const metrics = await queue.getMetrics(); - expect(metrics.failingJobs).toBe(0); - expect(metrics.jobsWithFailures).toHaveLength(1); - }); - }); - - // ── getJobs ────────────────────────────────────────────────────────────── - - describe("getJobs", () => { - it("maps scheduler jobs to summaries", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { - id: "m1", - active: true, - repeat: 60000, - lockedAt: null, - runCount: 5, - failCount: 1, - lastFailReason: "timeout", - lastRunAt: 1000, - lastFinishedAt: 1100, - lastFailedAt: 900, - data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, - }, - ]); - const { jobs } = await queue.getJobs({}); - - expect(jobs).toHaveLength(1); - expect(jobs[0]).toEqual({ - monitorId: "m1", - monitorUrl: "http://a.com", - monitorType: "http", - monitorInterval: 60000, - monitorGeoInterval: 300000, - monitorActive: true, - active: true, - repeat: 60000, - lockedBy: null, - lockedUntil: null, - lockedAt: null, - runCount: 5, - failCount: 1, - failReason: "timeout", - lastRunAt: 1000, - lastFinishedAt: 1100, - lastRunTook: 100, - lastFailedAt: 900, - }); - }); - - it("returns null lastRunTook when job is locked", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } }, - ]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].lastRunTook).toBeNull(); - }); - - it("handles jobs with missing data", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].monitorUrl).toBeNull(); - expect(jobs[0].monitorType).toBeNull(); - expect(jobs[0].monitorInterval).toBeNull(); - expect(jobs[0].monitorGeoInterval).toBeNull(); - expect(jobs[0].monitorActive).toBeNull(); - expect(jobs[0].repeat).toBeNull(); - }); - - it("exposes monitor.isActive as monitorActive in the summary", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } }, - { id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } }, - ]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].monitorActive).toBe(true); - expect(jobs[1].monitorActive).toBe(false); - }); - - it("exposes geo check interval and repeat", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue([ - { id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } }, - ]); - const { jobs } = await queue.getJobs({}); - expect(jobs[0].repeat).toBe(120000); - expect(jobs[0].monitorGeoInterval).toBe(600000); - }); - - it("paginates jobs and returns the total count", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue( - Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } })) - ); - - const { jobs, count } = await queue.getJobs({ page: 1, rowsPerPage: 10 }); - expect(count).toBe(25); - expect(jobs).toHaveLength(10); - expect(jobs[0].monitorId).toBe("m10"); - }); - - it("returns all jobs when rowsPerPage is omitted", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.getJobs as jest.Mock).mockResolvedValue( - Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } })) - ); - - const { jobs, count } = await queue.getJobs({}); - expect(count).toBe(25); - expect(jobs).toHaveLength(25); - }); - }); - - // ── flushQueues ────────────────────────────────────────────────────────── - - describe("flushQueues", () => { - it("stops, flushes, and reinitializes", async () => { - const { queue, scheduler } = createQueue(); - const result = await queue.flushQueues(); - expect(scheduler.stop).toHaveBeenCalled(); - expect(scheduler.flushJobs).toHaveBeenCalled(); - expect(result.success).toBe(true); - }); - - it("returns false when any step fails", async () => { - const { queue, scheduler } = createQueue(); - (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); - const result = await queue.flushQueues(); - expect(result.success).toBe(false); - }); - }); - - // ── static create ──────────────────────────────────────────────────────── - - describe("static create", () => { - it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => { - const logger = createMockLogger(); - const helper = createQueueHelper(); - const monitorsRepository = createMonitorsRepo(); - - const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any); - - expect(MockScheduler).toHaveBeenCalled(); - expect(instance).toBeInstanceOf(SuperSimpleQueue); - // init was called, which calls scheduler.start - expect(mockSchedulerInstance.start).toHaveBeenCalled(); - }); - }); -}); diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts deleted file mode 100644 index 7075051eb..000000000 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { describe, expect, it, jest } from "@jest/globals"; -import { QueueHelper } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; -import type { Monitor } from "../../../src/types/monitor.ts"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const createHelper = (overrides?: Record) => { - const maintenanceWindowsRepository = { - findByMonitorId: jest.fn().mockResolvedValue([]), - }; - const monitorsRepository = { - updateById: jest.fn().mockResolvedValue({}), - findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]), - deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0), - }; - const teamsRepository = { - findAllTeamIds: jest.fn().mockResolvedValue(["team"]), - }; - const monitorStatsRepository = { - deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), - }; - const checksRepository = { - deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), - }; - const incidentsRepository = { - deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), - }; - const geoChecksRepository = { - deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), - }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), - }; - const settingsServiceMock = { - getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), - }; - const geoChecksServiceMock = { - buildGeoCheck: jest.fn().mockResolvedValue(null), - }; - - const defaults = { - logger: createMockLogger(), - networkService: { requestStatus: jest.fn() }, - statusService: statusServiceMock, - notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) }, - settingsService: settingsServiceMock, - buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() }, - incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, - maintenanceWindowsRepository, - monitorsRepository, - teamsRepository, - monitorStatsRepository, - checksRepository, - incidentsRepository, - geoChecksService: geoChecksServiceMock, - geoChecksRepository, - ...overrides, - }; - - const helper = new QueueHelper( - defaults.logger as any, - defaults.networkService as any, - defaults.statusService as any, - defaults.notificationsService as any, - defaults.checkService as any, - defaults.settingsService as any, - defaults.buffer as any, - defaults.incidentService as any, - defaults.maintenanceWindowsRepository as any, - defaults.monitorsRepository as any, - defaults.teamsRepository as any, - defaults.monitorStatsRepository as any, - defaults.checksRepository as any, - defaults.incidentsRepository as any, - defaults.geoChecksService as any, - defaults.geoChecksRepository as any - ); - return { helper, defaults, maintenanceWindowsRepository }; -}; - -const makeMonitor = (overrides?: Partial): Monitor => - ({ - id: "m1", - teamId: "team", - type: "http", - interval: 60000, - status: "up", - geoCheckEnabled: false, - geoCheckLocations: [], - ...overrides, - }) as Monitor; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe("QueueHelper", () => { - describe("serviceName", () => { - it("returns JobQueueHelper", () => { - const { helper } = createHelper(); - expect(helper.serviceName).toBe("JobQueueHelper"); - }); - }); - - // ── getHeartbeatJob ────────────────────────────────────────────────────── - - describe("getHeartbeatJob", () => { - it("throws when monitor id is missing", async () => { - const { helper } = createHelper(); - const job = helper.getHeartbeatJob(); - await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); - }); - - it("skips execution and sets maintenance status when in maintenance window", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor({ status: "up" })); - - expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); - expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); - }); - - it("skips monitor status update when already in maintenance", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor({ status: "maintenance" })); - - expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); - }); - - it("throws when network response is null", async () => { - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await expect(job(makeMonitor())).rejects.toThrow("No network response"); - }); - - it("returns early and warns when buildCheck returns null", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); - expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); - }); - - it("processes full pipeline: network → check → status → incident", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.buffer.addToBuffer).toHaveBeenCalled(); - expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); - expect(defaults.incidentService.handleIncident).toHaveBeenCalled(); - }); - - it("sends notifications when decision says shouldSendNotification", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith( - statusResult.monitor, - networkResponse, - expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true }) - ); - }); - - it("logs error when notifications fail (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - - // Wait for the fire-and-forget catch to execute - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); - }); - - it("logs error when notifications fail with non-Error (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; - const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; - const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); - }); - - it("logs warning when incident handling fails (fire-and-forget)", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - incidentService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); - }); - - it("logs warning when incident handling fails with non-Error", async () => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) }; - const { helper, defaults } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - incidentService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await job(makeMonitor()); - await new Promise((r) => setTimeout(r, 10)); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); - }); - - it("logs and rethrows on unexpected error with non-Error", async () => { - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - - await expect(job(makeMonitor())).rejects.toBe("unexpected"); - }); - }); - - // ── getHeartbeatGeoJob ─────────────────────────────────────────────────── - - describe("getHeartbeatGeoJob", () => { - it("throws nothing when monitor id is missing (logs error)", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job({} as Monitor); - - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" })); - }); - - it("returns early when geoCheckEnabled is false", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: false })); - - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("returns early when monitor type does not support geo checks", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.debug).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }) - ); - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("returns early when geoCheckLocations is empty", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); - }); - - it("returns early when geoCheckLocations is undefined", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); - }); - - it("skips when in maintenance window", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); - expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); - }); - - it("warns when buildGeoCheck returns null", async () => { - const { helper, defaults } = createHelper(); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); - }); - - it("adds geo check to buffer on success", async () => { - const geoCheck = { id: "gc-1", monitorId: "m1" }; - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); - expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); - }); - - it("logs error on unexpected failure without rethrowing", async () => { - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" })); - }); - - it("logs 'Unknown error' for non-Error exceptions", async () => { - const { helper, defaults } = createHelper({ - geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatGeoJob(); - - await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); - - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); - }); - }); - - // ── getCleanupOrphanedJob ──────────────────────────────────────────────── - - describe("getCleanupOrphanedJob", () => { - it("cleans up orphaned data across all repositories", async () => { - const { helper, defaults } = createHelper(); - (defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2); - (defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3); - (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); - (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); - (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); - - const job = helper.getCleanupOrphanedJob(); - await job(); - - expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled(); - expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]); - expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled(); - expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); - expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); - expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); - expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); - // Logs info for each deleted count > 0 - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); - }); - - it("skips info logs when no orphaned data is found", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getCleanupOrphanedJob(); - await job(); - - // Only start and completion logs, no deletion logs - const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message); - expect(infoMessages).toContain("Starting cleanup of orphaned data"); - expect(infoMessages).toContain("Cleanup of orphaned data completed"); - expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors")); - }); - - it("logs warning and rethrows on error", async () => { - const { helper, defaults } = createHelper(); - (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down")); - const job = helper.getCleanupOrphanedJob(); - - await expect(job()).rejects.toThrow("db down"); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" })); - }); - - it("logs 'Unknown error' for non-Error exceptions", async () => { - const { helper, defaults } = createHelper(); - (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null); - const job = helper.getCleanupOrphanedJob(); - - await expect(job()).rejects.toBeNull(); - expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); - }); - }); - - // ── getCleanupRetentionJob ─────────────────────────────────────────────── - - describe("getCleanupRetentionJob", () => { - it("deletes checks older than TTL cutoff", async () => { - const { helper, defaults } = createHelper(); - const job = helper.getCleanupRetentionJob(); - await job(); - - expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") })); - }); - - it("skips cleanup when checkTTL is sentinel value (366)", async () => { - const { helper, defaults } = createHelper({ - settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) }, - }); - const job = helper.getCleanupRetentionJob(); - await job(); - - expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled(); - expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") })); - }); - - it("logs error on failure without rethrowing", async () => { - const { helper, defaults } = createHelper({ - settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) }, - }); - const job = helper.getCleanupRetentionJob(); - - await job(); // Should not throw - - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" })); - }); - - it("logs 'Unknown error' for non-Error exceptions", async () => { - const { helper, defaults } = createHelper({ - settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") }, - }); - const job = helper.getCleanupRetentionJob(); - await job(); - - expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); - }); - }); - - // ── isInMaintenanceWindow ──────────────────────────────────────────────── - - describe("isInMaintenanceWindow", () => { - it("returns true when an active window spans now", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns true when repeat interval advances window into current time", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 7200000).toISOString(), - end: new Date(now - 6600000).toISOString(), - repeat: 3600000, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns false when no active windows exist", async () => { - const { helper } = createHelper(); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when window is inactive", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: false, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when active window is in the past without repeat", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 5000).toISOString(), - end: new Date(now - 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("returns false when repeat advances past current time without match", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - // Window was 2 hours ago, lasts 10 minutes, repeats every hour - // First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match - // Use a repeat that skips over now - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago - end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window - repeat: 3600000, // 1 hour repeat - }, - ]); - // Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min - // Let's use a different setup that clearly doesn't match - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago - end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window - repeat: 3600000, // 1 hour repeat — next window would be in 29 min - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - - it("preserves previous true result via accumulator", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - { - active: false, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - // Second window is inactive but first already matched — accumulator should preserve true - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - }); - - // ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ───────── - - describe("evaluateMonitorAction", () => { - const runJobAndGetDecision = async (statusResult: Record) => { - const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; - const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) }; - const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) }; - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, - incidentService, - notificationsService, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - await job(makeMonitor()); - return { incidentService, notificationsService }; - }; - - it("does nothing when statusChanged is false", async () => { - const { incidentService, notificationsService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: false, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), - expect.anything() - ); - }); - - it("creates incident and notifies when monitor goes down", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "down" }, - statusChanged: true, - prevStatus: "up", - code: 500, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - shouldCreateIncident: true, - shouldSendNotification: true, - incidentReason: "status_down", - notificationReason: "status_change", - }), - expect.anything() - ); - }); - - it("creates incident and notifies when monitor is breached", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "breached" }, - statusChanged: true, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - shouldCreateIncident: true, - incidentReason: "threshold_breach", - notificationReason: "threshold_breach", - }), - expect.anything() - ); - }); - - it("resolves incident when monitor recovers from down", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: true, - prevStatus: "down", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }), - expect.anything() - ); - }); - - it("resolves incident when monitor recovers from breached", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "up" }, - statusChanged: true, - prevStatus: "breached", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldResolveIncident: true }), - expect.anything() - ); - }); - - it("does not create or resolve for unhandled status transitions", async () => { - const { incidentService } = await runJobAndGetDecision({ - monitor: { id: "m1", status: "paused" }, - statusChanged: true, - prevStatus: "up", - code: 200, - }); - expect(incidentService.handleIncident).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), - expect.anything() - ); - }); - }); -}); diff --git a/server/test/unit/services/tagsService.test.ts b/server/test/unit/services/tagsService.test.ts index d611d641e..7f99906d7 100644 --- a/server/test/unit/services/tagsService.test.ts +++ b/server/test/unit/services/tagsService.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { TagsService } from "../../../src/service/infrastructure/tagService.ts"; -import type { Tag } from "../../../src/types/index.ts"; +import { TagsService } from "../../../src/domain/tags/tag.service.ts"; +import type { Tag } from "../../../src/domain/tags/tag.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index 51590fb38..061003e7f 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { UserService } from "../../../src/service/business/userService.ts"; +import { UserService } from "../../../src/domain/users/user.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { User, UserRole } from "../../../src/types/index.ts"; +import type { User, UserRole } from "../../../src/domain/users/user.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -65,7 +65,7 @@ const createService = (overrides?: Record) => { const settingsService = { getSettings: jest.fn().mockReturnValue(makeAppSettings()), }; - const jobQueue = { + const scheduler = { deleteJob: jest.fn().mockResolvedValue(undefined), }; const jwtMock = { @@ -85,7 +85,7 @@ const createService = (overrides?: Record) => { monitorsRepository, emailService, settingsService, - jobQueue, + scheduler, jwt: jwtMock, crypto: cryptoMock, ...overrides, @@ -475,23 +475,23 @@ describe("UserService", () => { describe("deleteUser", () => { it("deletes user and cleans up monitor jobs for superadmin", async () => { - const { service, usersRepository, jobQueue, monitorsRepository } = createService(); + const { service, usersRepository, scheduler, monitorsRepository } = createService(); await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); - expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2); + expect(scheduler.deleteJob).toHaveBeenCalledTimes(2); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); it("handles null monitors for superadmin gracefully", async () => { - const { service, usersRepository, jobQueue } = createService({ + const { service, usersRepository, scheduler } = createService({ monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) }, }); await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); - expect(jobQueue.deleteJob).not.toHaveBeenCalled(); + expect(scheduler.deleteJob).not.toHaveBeenCalled(); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); diff --git a/server/test/unit/services/workerHelper.test.ts b/server/test/unit/services/workerHelper.test.ts new file mode 100644 index 000000000..96b45d3b3 --- /dev/null +++ b/server/test/unit/services/workerHelper.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { WorkerHelper } from "../../../src/worker/worker.helper.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── +// +// WorkerHelper is now thin glue: it wires the geo job to the GeoChecksPipeline and +// owns the cleanup jobs. The pipeline internals are tested in checkPipeline.test.ts; +// here we test only the glue + cleanup jobs. + +const createHelper = (overrides?: Record) => { + const monitorsRepository = { + updateById: jest.fn().mockResolvedValue({}), + findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]), + deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const jobsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const teamsRepository = { + findAllTeamIds: jest.fn().mockResolvedValue(["team"]), + }; + const monitorStatsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const checksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const incidentsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const geoChecksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const settingsService = { + getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), + }; + const checkService = { + deleteOlderThan: jest.fn().mockResolvedValue(0), + }; + + const defaults = { + logger: createMockLogger(), + checkService, + settingsService, + monitorsRepository, + jobsRepository, + teamsRepository, + monitorStatsRepository, + checksRepository, + incidentsRepository, + geoChecksRepository, + ...overrides, + }; + + const helper = new WorkerHelper( + defaults.logger as any, + defaults.checkService as any, + defaults.settingsService as any, + defaults.monitorsRepository as any, + defaults.jobsRepository as any, + defaults.teamsRepository as any, + defaults.monitorStatsRepository as any, + defaults.checksRepository as any, + defaults.incidentsRepository as any, + defaults.geoChecksRepository as any + ); + return { helper, defaults }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("WorkerHelper", () => { + describe("serviceName", () => { + it("returns JobQueueHelper", () => { + const { helper } = createHelper(); + expect(helper.serviceName).toBe("JobQueueHelper"); + }); + }); + + // ── getCleanupOrphanedJob ──────────────────────────────────────────────── + + describe("getCleanupOrphanedJob", () => { + it("cleans up orphaned data across all repositories", async () => { + const { helper, defaults } = createHelper(); + (defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2); + (defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3); + (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); + (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); + (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + (defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6); + + const job = helper.getCleanupOrphanedJob(); + await job(); + + expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled(); + expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]); + expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled(); + expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") })); + }); + + it("skips info logs when no orphaned data is found", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupOrphanedJob(); + await job(); + + const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message); + expect(infoMessages).toContain("Starting cleanup of orphaned data"); + expect(infoMessages).toContain("Cleanup of orphaned data completed"); + expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors")); + }); + + it("logs warning and rethrows on error", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down")); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toThrow("db down"); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupRetentionJob ─────────────────────────────────────────────── + + describe("getCleanupRetentionJob", () => { + it("deletes checks older than TTL cutoff", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") })); + }); + + it("skips cleanup when checkTTL is sentinel value (366)", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled(); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") })); + }); + + it("logs error on failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) }, + }); + const job = helper.getCleanupRetentionJob(); + + await job(); // Should not throw + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); +}); diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts new file mode 100644 index 000000000..bbdac7118 --- /dev/null +++ b/server/test/unit/utils/customCss.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@jest/globals"; +import { cssReferencesExternalResource } from "../../../src/utils/customCss.ts"; + +describe("cssReferencesExternalResource", () => { + it("returns false for empty or non-string input", () => { + expect(cssReferencesExternalResource("")).toBe(false); + expect(cssReferencesExternalResource(null)).toBe(false); + expect(cssReferencesExternalResource(undefined)).toBe(false); + }); + + it("allows benign CSS", () => { + expect(cssReferencesExternalResource("li{outline:3px dashed red}footer{display:none}")).toBe(false); + }); + + it("allows data: and relative urls", () => { + expect(cssReferencesExternalResource("li{background:url(data:image/png;base64,AAAA)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(/assets/logo.png)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(logo.png)}")).toBe(false); + }); + + it("allows data: SVG that embeds the SVG namespace URL", () => { + const css = `li{background:url("data:image/svg+xml,")}`; + expect(cssReferencesExternalResource(css)).toBe(false); + }); + + it("flags @import", () => { + expect(cssReferencesExternalResource('@import url("https://evil.example/x.css");')).toBe(true); + expect(cssReferencesExternalResource('@import "https://evil.example/x.css";')).toBe(true); + }); + + it("flags external url()", () => { + expect(cssReferencesExternalResource('body{background:url("https://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x)}")).toBe(true); + }); + + it("flags protocol-relative url()", () => { + expect(cssReferencesExternalResource("body{background:url(//evil.example/x)}")).toBe(true); + }); + + it("flags external string-form image functions", () => { + expect(cssReferencesExternalResource('body{background:image-set("https://evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background:-webkit-image-set("//evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background-image:image("https://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with a trailing parse error", () => { + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); + }); + + it("flags references obfuscated with inline comments", () => { + expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true); + expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true); + }); + + it("allows a comment that makes url() a bad token the browser never fetches", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false); + }); + + it("flags url() obfuscated with CSS escapes", () => { + expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); + expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); + }); + + it("flags a scheme split by a line continuation", () => { + expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true); + }); + + it("flags a scheme split by a hex escape", () => { + expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true); + }); +}); diff --git a/server/test/unit/utils/maintenanceWindow.test.ts b/server/test/unit/utils/maintenanceWindow.test.ts index 5a3a39b6a..4c4ed3337 100644 --- a/server/test/unit/utils/maintenanceWindow.test.ts +++ b/server/test/unit/utils/maintenanceWindow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@jest/globals"; import { isWindowActive } from "../../../src/utils/maintenanceWindow.ts"; -import type { MaintenanceWindow } from "../../../src/types/index.ts"; +import type { MaintenanceWindow } from "../../../src/domain/maintenance-windows/maintenance-window.type.ts"; const makeWindow = (overrides?: Partial): MaintenanceWindow => { const now = Date.now(); diff --git a/server/test/unit/utils/statusPageDomain.test.ts b/server/test/unit/utils/statusPageDomain.test.ts new file mode 100644 index 000000000..37cd03633 --- /dev/null +++ b/server/test/unit/utils/statusPageDomain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@jest/globals"; +import { normalizeStatusPageDomain, resolveStatusPageDomainFromRequest } from "../../../src/utils/statusPageDomain.ts"; + +describe("statusPageDomain utils", () => { + describe("normalizeStatusPageDomain", () => { + it("returns null for empty values", () => { + expect(normalizeStatusPageDomain("")).toBeNull(); + expect(normalizeStatusPageDomain(" ")).toBeNull(); + expect(normalizeStatusPageDomain(null)).toBeNull(); + }); + + it("normalizes hostnames", () => { + expect(normalizeStatusPageDomain("Status.Example.COM")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("https://status.example.com/path")).toBe("status.example.com"); + expect(normalizeStatusPageDomain("status.example.com:443")).toBe("status.example.com"); + }); + }); + + describe("resolveStatusPageDomainFromRequest", () => { + it("prefers the query domain over the host header", () => { + expect(resolveStatusPageDomainFromRequest("checkmate.example.com", "status.example.com")).toBe("status.example.com"); + }); + + it("falls back to the host header", () => { + expect(resolveStatusPageDomainFromRequest("status.example.com:8443", undefined)).toBe("status.example.com"); + }); + }); +}); diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts index a209ba64f..d77fabd2c 100644 --- a/server/test/unit/validation/maintenanceWindowValidation.test.ts +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, jest, afterEach } from "@jest/globals"; import { createMaintenanceWindowBodyValidation, editMaintenanceByIdWindowBodyValidation, -} from "../../../src/validation/maintenanceWindowValidation.ts"; +} from "../../../src/api/validation/maintenanceWindowValidation.ts"; const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 057ff6ae1..d7a68b00b 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "@jest/globals"; -import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts"; +import { + createMonitorBodyValidation, + editMonitorBodyValidation, + importMonitorsBodyValidation, +} from "../../../src/api/validation/monitorValidation.ts"; const baseDnsBody = { name: "DNS check", @@ -222,3 +226,200 @@ describe("monitorValidation — strategy gating", () => { }); }); }); + +describe("monitorValidation — HEAD method gating", () => { + const baseHead = { + name: "HEAD check", + type: "http" as const, + url: "https://example.com", + method: "HEAD" as const, + }; + + describe("createMonitorBodyValidation", () => { + it("accepts a HEAD monitor with no body matching", () => { + const parsed = createMonitorBodyValidation.parse(baseHead); + expect(parsed.method).toBe("HEAD"); + }); + + it("accepts GET together with advanced matching", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHead, + method: "GET", + useAdvancedMatching: true, + jsonPath: "status", + }); + expect(parsed.method).toBe("GET"); + }); + + it("rejects HEAD combined with advanced matching", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHead, useAdvancedMatching: true })).toThrow(); + }); + + it("rejects HEAD combined with a non-empty jsonPath", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "status" })).toThrow(); + }); + + it("allows HEAD with an empty-string jsonPath", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "" }); + expect(parsed.method).toBe("HEAD"); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("rejects HEAD combined with advanced matching", () => { + expect(() => editMonitorBodyValidation.parse({ method: "HEAD", useAdvancedMatching: true })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("rejects HEAD combined with a jsonPath on imported monitors", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHead, jsonPath: "status" }] })).toThrow(); + }); + }); +}); + +describe("monitorValidation — customUpCodes", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("accepts valid HTTP status codes", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [200, 301, 404, 503], + }); + expect(parsed.customUpCodes).toEqual([200, 301, 404, 503]); + }); + + it("accepts non-standard HTTP status codes (Cloudflare, AWS ELB, etc.)", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }); + expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]); + }); + + it("rejects invalid HTTP status codes", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [5000], + }) + ).toThrow(); + + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [-1], + }) + ).toThrow(); + }); + + it("rejects fractional or floating-point status codes", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [200.5], + }) + ).toThrow(); + }); + + it("rejects status codes below 100", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [99], + }) + ).toThrow(); + }); + + it("rejects valid-looking but mathematically unsupported status codes (e.g. 599)", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseHttpBody, + customUpCodes: [599], // Not in Node's default list nor our expanded list + }) + ).toThrow(); + }); + + it("defaults to an empty array when not provided", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + expect(parsed.customUpCodes).toEqual([]); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("accepts valid HTTP status codes on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + customUpCodes: [200, 201], + }); + expect(parsed.customUpCodes).toEqual([200, 201]); + }); + + it("accepts non-standard HTTP status codes on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }); + expect(parsed.customUpCodes).toEqual([419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561]); + }); + + it("rejects invalid HTTP status codes on edits", () => { + expect(() => + editMonitorBodyValidation.parse({ + customUpCodes: [9999], + }) + ).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains valid HTTP status codes on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [404], + }, + ], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([404]); + }); + + it("accepts non-standard HTTP status codes on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561], + }, + ], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([ + 419, 420, 440, 449, 460, 463, 497, 499, 509, 520, 521, 522, 523, 524, 525, 526, 527, 529, 530, 561, + ]); + }); + + it("rejects invalid HTTP status codes on imported HTTP monitors", () => { + expect(() => + importMonitorsBodyValidation.parse({ + monitors: [ + { + ...baseHttpBody, + customUpCodes: [5000], + }, + ], + }) + ).toThrow(); + }); + + it("defaults to an empty array when not provided on import", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [baseHttpBody], + }); + expect(parsed.monitors[0].customUpCodes).toEqual([]); + }); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts new file mode 100644 index 000000000..a2d659c34 --- /dev/null +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@jest/globals"; +import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; + +const baseCreateBody = (overrides: Record = {}) => ({ + type: "uptime", + companyName: "Test Co", + url: "my-status-page", + monitors: ["0123456789abcdef01234567"], + isPublished: true, + showUptimePercentage: true, + ...overrides, +}); + +describe("createStatusPageBodyValidation", () => { + it("accepts a valid body without customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts and preserves customCSS", () => { + const customCSS = ".status-page > footer { display: none; }"; + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(customCSS); + } + }); + + it("accepts an empty customCSS so it can be cleared", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "" })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(""); + } + }); + + it("rejects customCSS over the maximum length", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "a".repeat(100001) })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "Custom CSS must be at most 100000 characters", path: ["customCSS"] })]) + ); + } + }); + + it("rejects a non-string customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); + expect(result.success).toBe(false); + }); + + it("rejects customCSS that references an external resource", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 'body{background:url("https://evil.example/beacon")}' })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: "Custom CSS cannot reference external URLs or use @import", path: ["customCSS"] }), + ]) + ); + } + }); +}); diff --git a/server/test/unit/validation/userValidation.test.ts b/server/test/unit/validation/userValidation.test.ts index 2e263d1a9..c50a70caa 100644 --- a/server/test/unit/validation/userValidation.test.ts +++ b/server/test/unit/validation/userValidation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { editUserBodyValidation } from "../../../src/validation/userValidation.ts"; +import { editUserBodyValidation } from "../../../src/api/validation/userValidation.ts"; const validPassword = "OldPass1!"; const validNewPassword = "NewPass1!"; diff --git a/server/tsconfig.json b/server/tsconfig.json index d4544d9bb..41f1ab64c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "allowJs": true, "checkJs": false, + "baseUrl": ".", "paths": { "@/*": ["./src/*"] },