Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825bcc5fdb | ||
|
|
a4ab7d662e | ||
|
|
69cdf948da | ||
|
|
807d7e10c3 | ||
|
|
d337bb235c | ||
|
|
39043f3945 | ||
|
|
0c0f43174d | ||
|
|
5a72e1e123 | ||
|
|
959b150ebf | ||
|
|
51149d1dd4 | ||
|
|
552b7a037d | ||
|
|
8671685cf9 | ||
|
|
ff536f40a2 | ||
|
|
e3d63a1cb4 | ||
|
|
105d7f0580 | ||
|
|
e2a1f02971 | ||
|
|
b7f548ff15 | ||
|
|
29d05c1e57 | ||
|
|
0ec77a5ab1 | ||
|
|
95eb75f416 | ||
|
|
8f657dcd36 | ||
|
|
c0e33eb4fa | ||
|
|
754b0af3a3 | ||
|
|
a39792cf0b | ||
|
|
35c9618d59 | ||
|
|
c6c41c2e3e | ||
|
|
f58825d19f | ||
|
|
e4649f3d76 | ||
|
|
c7361666f5 | ||
|
|
3f5707721f | ||
|
|
cc5f033d0d | ||
|
|
d2d0fb788e | ||
|
|
9d969d67ca | ||
|
|
6e1c26b987 | ||
|
|
ef353dcb36 | ||
|
|
b21ef60cda | ||
|
|
f43bf9ab3d | ||
|
|
48ca64ec6f | ||
|
|
e036c6077e | ||
|
|
c9f0254aed | ||
|
|
b3d9c5b94c | ||
|
|
fd536541d7 | ||
|
|
7a82c76d5c | ||
|
|
5fab2d0a30 | ||
|
|
860b2d6214 | ||
|
|
8579edd56e | ||
|
|
969d24e369 | ||
|
|
52b95ad121 | ||
|
|
ea23610b3a | ||
|
|
a4cc322a55 | ||
|
|
2b9ff52f6b | ||
|
|
0f230c4d70 | ||
|
|
74783fd738 | ||
|
|
71d3e789ff | ||
|
|
f59a7c3113 | ||
|
|
ffbf223288 | ||
|
|
96021b7479 | ||
|
|
f0a5974eaf | ||
|
|
c9b8f434cd | ||
|
|
3685e0c476 | ||
|
|
d6f9fca907 | ||
|
|
ba943e90c2 | ||
|
|
dead97169f | ||
|
|
eb1d307932 | ||
|
|
a31dd2f19e | ||
|
|
d84fc7e299 | ||
|
|
8afd67a06d | ||
|
|
71a4fddffd | ||
|
|
3489ee000d | ||
|
|
93b9c5033a | ||
|
|
32ec909bf7 | ||
|
|
8e5757beb1 | ||
|
|
8e5a337f3e | ||
|
|
9db4e557b3 | ||
|
|
10174da4f0 | ||
|
|
99ded83bd5 | ||
|
|
dffa49ae9d | ||
|
|
89c86a7628 | ||
|
|
09446c9fc1 | ||
|
|
755134ee7f | ||
|
|
3b919044e6 | ||
|
|
08e9c02a3a | ||
|
|
85f674ee08 | ||
|
|
16036f421b | ||
|
|
7fe7b79103 | ||
|
|
a058c0518c | ||
|
|
5ed9ab780a | ||
|
|
e32590425b | ||
|
|
190a3b47da | ||
|
|
f740eef0fa | ||
|
|
5efd64d44d | ||
|
|
b28c84436e | ||
|
|
9082cf0826 | ||
|
|
9d453bdb0b | ||
|
|
39d92fb067 | ||
|
|
a54fdd89ab | ||
|
|
6aa9360b98 | ||
|
|
bb3f471d19 | ||
|
|
6d433fd0ac | ||
|
|
d8dafa311a | ||
|
|
398e5d7314 | ||
|
|
53ee45172d | ||
|
|
1ea6dafb1a | ||
|
|
9785dc146e | ||
|
|
1b1d8ea229 | ||
|
|
603bd1d814 | ||
|
|
158d976ccb | ||
|
|
380fdf470f | ||
|
|
1e2a4b4a84 | ||
|
|
5e0d8f0550 | ||
|
|
bffc4ee813 | ||
|
|
ad9ff7f5df | ||
|
|
8277c4ed13 | ||
|
|
f65238da6c | ||
|
|
64b8aab4d6 | ||
|
|
7244b35091 | ||
|
|
699883e0b1 | ||
|
|
f7211711ce | ||
|
|
cb0dd878d6 | ||
|
|
8c5be9ef00 | ||
|
|
d1b6d08c73 | ||
|
|
2d68e96782 | ||
|
|
306f2a60a2 | ||
|
|
abf6dc2908 | ||
|
|
7ce6068757 | ||
|
|
a7d9bb822e | ||
|
|
142ecaaf8b | ||
|
|
90bbc764fc | ||
|
|
ae4c433246 | ||
|
|
d2461ef118 | ||
|
|
47162f2064 | ||
|
|
8b9c41464a | ||
|
|
51b6ec1751 | ||
|
|
6165ae60e6 | ||
|
|
69e142f711 | ||
|
|
eff4f8ad69 | ||
|
|
bca902471d | ||
|
|
1b3f2f0365 | ||
|
|
be12991997 | ||
|
|
54596fe776 | ||
|
|
8d637dbcac | ||
|
|
771fd5b50f | ||
|
|
8dedf2a742 | ||
|
|
c882daf000 | ||
|
|
1aa7ae8f11 | ||
|
|
517139cbf4 | ||
|
|
ef049b6521 | ||
|
|
33f8641f3a | ||
|
|
2f22a52116 | ||
|
|
8af13b04ec | ||
|
|
ac77216f07 | ||
|
|
a43363ebfc | ||
|
|
0ca738b080 | ||
|
|
091c36cbf3 | ||
|
|
120708ed47 | ||
|
|
6a1ab622b3 | ||
|
|
0aef8b652f | ||
|
|
20863fbce7 | ||
|
|
fa7eaa4788 | ||
|
|
ee9c08b1bb | ||
|
|
7ee30e12f5 | ||
|
|
c5f57eda62 | ||
|
|
2bf3c08f3c | ||
|
|
187c8eaba1 | ||
|
|
25d8839441 | ||
|
|
0f4240d3db | ||
|
|
a37bc63f31 | ||
|
|
73f95ac8e1 | ||
|
|
c8c4bac2c7 | ||
|
|
9fecfa562f | ||
|
|
7991f3f6a1 | ||
|
|
692d4d89d6 | ||
|
|
f1b104b78d | ||
|
|
3c2b571313 | ||
|
|
abec4e6c70 | ||
|
|
d022f7d849 | ||
|
|
0fb210e9e1 | ||
|
|
ce4cb63035 | ||
|
|
f637b01631 | ||
|
|
e390bd764f | ||
|
|
30e8cfc9ec | ||
|
|
80d6868a1d | ||
|
|
942d75afcd | ||
|
|
c34fcc5cb3 | ||
|
|
409f9f6dc6 | ||
|
|
525206c1c1 | ||
|
|
9a85389b8c | ||
|
|
eeac784922 | ||
|
|
19b873ca87 | ||
|
|
0f99c7004d | ||
|
|
6a566c3693 | ||
|
|
dfcf0309fe | ||
|
|
395b7fbe2a | ||
|
|
448fcc4628 | ||
|
|
ee5462d039 | ||
|
|
d2c13fac5a | ||
|
|
dd16ac060a | ||
|
|
5628c202f7 | ||
|
|
372fc9869b | ||
|
|
87a6640c1d | ||
|
|
4e31499f78 | ||
|
|
7ee6d342e9 | ||
|
|
49d3737a93 | ||
|
|
4402e4fbfa | ||
|
|
721f77ed26 | ||
|
|
34f29e5ae6 | ||
|
|
c2170267e8 | ||
|
|
321ca59380 | ||
|
|
e3ecfe6ad8 | ||
|
|
f4664eec16 | ||
|
|
56f601e34b | ||
|
|
1244ff6d93 | ||
|
|
c0ea804c98 | ||
|
|
c21cf29db6 | ||
|
|
3d0da70ce6 | ||
|
|
feb061240b | ||
|
|
a924227805 | ||
|
|
2144d3f8c0 | ||
|
|
fcd6786149 | ||
|
|
ea968ebf3c | ||
|
|
8a7b852974 | ||
|
|
c8d3e3b70c | ||
|
|
7a90a6f9f4 |
No files matched your search
@@ -68,19 +68,23 @@ VITE_APP_LOG_LEVEL="debug"
|
||||
### Backend Layers
|
||||
```
|
||||
server/src/
|
||||
├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||
├── service/ # Business logic
|
||||
│ ├── business/ # Core monitoring logic
|
||||
│ ├── infrastructure/ # Server/system utilities
|
||||
│ └── system/ # App-level settings
|
||||
├── api/ # HTTP layer
|
||||
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||
│ ├── routes/ # API route definitions
|
||||
│ └── validation/ # Zod request-payload schemas
|
||||
├── domain/ # Business logic + data access, one folder per entity
|
||||
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
|
||||
│ ├── checks/
|
||||
│ ├── incidents/
|
||||
│ └── … # users, teams, status-pages, notifications, etc.
|
||||
├── service/ # Cross-cutting services (e.g. infrastructure/)
|
||||
├── config/ # App config wiring (controllers, routes, services, envValidation)
|
||||
├── db/
|
||||
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
|
||||
│ ├── migration/ # Database migrations (run on startup)
|
||||
│ └── modules/ # Database-specific modules
|
||||
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||
├── routes/v1/ # API route definitions
|
||||
├── validation/ # Joi input validation schemas
|
||||
└── repositories/ # Data access layer
|
||||
│ └── migration/ # Database migrations (run on startup)
|
||||
├── templates/ # Email/notification templates
|
||||
├── types/ # Shared const tuples + derived types
|
||||
└── utils/ # Shared utilities
|
||||
```
|
||||
|
||||
### Frontend Structure
|
||||
|
||||
@@ -2,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)
|
||||
@@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate
|
||||
|
||||
### 2. Customize values.yaml
|
||||
Edit `values.yaml` to update:
|
||||
- `client.ingress.host` and `server.ingress.host` with your domain names
|
||||
- `server.protocol` (usually http or https)
|
||||
- `client.ingress.host` and `api.ingress.host` with your domain names
|
||||
- `api.protocol` (usually http or https)
|
||||
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
||||
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
||||
- Old: `persistence.redisSize` → New: `persistence.redis.size`
|
||||
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
|
||||
- Add: `persistence.mongo.storageClass` (leave empty for default)
|
||||
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
|
||||
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
|
||||
|
||||
@@ -32,7 +31,7 @@ Edit `values.yaml` to update:
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate
|
||||
```
|
||||
This will deploy the client, server, MongoDB, and Redis components.
|
||||
This will deploy the client, api, and MongoDB components.
|
||||
|
||||
### 4. Verify the deployment
|
||||
Check pods and services:
|
||||
@@ -43,6 +42,137 @@ kubectl get svc
|
||||
|
||||
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
|
||||
|
||||
## Upgrading from a previous release
|
||||
|
||||
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
|
||||
Read this before running `helm upgrade` on an existing install.
|
||||
|
||||
### `server.*` → `api.*` (values rename)
|
||||
|
||||
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
|
||||
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
|
||||
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
|
||||
|
||||
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
|
||||
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
|
||||
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
|
||||
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
|
||||
don't mix them.
|
||||
|
||||
### Resource renames cause a replace, not a rename
|
||||
|
||||
The Kubernetes objects are renamed alongside the values:
|
||||
|
||||
| Old object | New object |
|
||||
| --- | --- |
|
||||
| Deployment/Service `checkmate-server` | `checkmate-api` |
|
||||
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
|
||||
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
|
||||
|
||||
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
|
||||
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
|
||||
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
|
||||
|
||||
### Redis removed
|
||||
|
||||
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
|
||||
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
|
||||
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
|
||||
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
|
||||
keys in your values file are harmless (ignored).
|
||||
|
||||
### New secret defaults
|
||||
|
||||
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
|
||||
block and merge into your install unless you override them. Set them explicitly if you relied on
|
||||
different values (e.g. a non-production `NODE_ENV`).
|
||||
|
||||
### Image tags follow the chart's `appVersion`
|
||||
|
||||
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
|
||||
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
|
||||
chart. You can still pin per tier:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
|
||||
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
|
||||
```
|
||||
|
||||
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
|
||||
`api.image`) unless you set `worker.tag` / `worker.image`.
|
||||
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
|
||||
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
|
||||
|
||||
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
|
||||
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
|
||||
let it default to `appVersion`) for reproducible, rollback-able deploys.
|
||||
|
||||
## Scaling: the worker tier & autoscaling
|
||||
|
||||
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
|
||||
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
|
||||
horizontally-scalable worker tier.
|
||||
|
||||
### Topology
|
||||
|
||||
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
|
||||
| --- | --- | --- |
|
||||
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
|
||||
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
|
||||
|
||||
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
|
||||
and worker tier can never both process (or both ignore) the queue.
|
||||
|
||||
### Enabling the worker tier
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2 # used only when autoscaling.enabled = false
|
||||
```
|
||||
|
||||
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
|
||||
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
|
||||
|
||||
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
|
||||
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
|
||||
> checks.
|
||||
|
||||
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
|
||||
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
|
||||
> this; full failover to the API would need leader election (not supported).
|
||||
|
||||
### Autoscaling with KEDA
|
||||
|
||||
Backlog-driven autoscaling is **off by default** and requires the
|
||||
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
enabled: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
|
||||
maxReplicaCount: 10
|
||||
backlogPerReplica: 50 # target due-job backlog per worker
|
||||
mongo:
|
||||
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
|
||||
```
|
||||
|
||||
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
|
||||
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
|
||||
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
|
||||
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
|
||||
backlog → never scales up).
|
||||
|
||||
#### Fallback: Prometheus-adapter HPA (not shipped)
|
||||
|
||||
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
|
||||
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
|
||||
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
|
||||
`now` in code on this path, so the query coercion is unnecessary.
|
||||
|
||||
## Enabling TLS/HTTPS with cert-manager
|
||||
|
||||
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
|
||||
@@ -68,7 +198,7 @@ client:
|
||||
enabled: true
|
||||
secretName: checkmate-client-tls
|
||||
|
||||
server:
|
||||
api:
|
||||
protocol: https
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -78,7 +208,7 @@ server:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: checkmate-server-tls
|
||||
secretName: checkmate-api-tls
|
||||
```
|
||||
|
||||
### Alternative: Using --set flags
|
||||
@@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate \
|
||||
--set client.protocol=https \
|
||||
--set server.protocol=https \
|
||||
--set api.protocol=https \
|
||||
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set client.ingress.tls.enabled=true \
|
||||
--set client.ingress.tls.secretName=checkmate-client-tls \
|
||||
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set server.ingress.tls.enabled=true \
|
||||
--set server.ingress.tls.secretName=checkmate-server-tls
|
||||
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set api.ingress.tls.enabled=true \
|
||||
--set api.ingress.tls.secretName=checkmate-api-tls
|
||||
```
|
||||
|
||||
### Verification
|
||||
@@ -107,7 +237,7 @@ kubectl get certificates
|
||||
|
||||
# Check certificate details
|
||||
kubectl describe certificate checkmate-client-tls
|
||||
kubectl describe certificate checkmate-server-tls
|
||||
kubectl describe certificate checkmate-api-tls
|
||||
|
||||
# Verify the secrets were created
|
||||
kubectl get secrets | grep checkmate-tls
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- /*
|
||||
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
|
||||
still pass a legacy `server:` block keep working — this returns the effective API values with any
|
||||
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
|
||||
|
||||
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
then reference `$api.*` instead of `.Values.api.*`.
|
||||
*/}}
|
||||
{{- define "checkmate.api" -}}
|
||||
{{- $api := deepCopy .Values.api -}}
|
||||
{{- with .Values.server -}}
|
||||
{{- $api = mergeOverwrite $api (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- $api | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /*
|
||||
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
|
||||
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
|
||||
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
|
||||
|
||||
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
|
||||
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
|
||||
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
|
||||
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
|
||||
*/}}
|
||||
{{- define "checkmate.image" -}}
|
||||
{{- $lastSegment := .image | splitList "/" | last -}}
|
||||
{{- if contains ":" $lastSegment -}}
|
||||
{{- .image -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-api
|
||||
spec:
|
||||
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-api
|
||||
spec:
|
||||
{{- with $api.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $api.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: api
|
||||
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
|
||||
imagePullPolicy: {{ $api.imagePullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $api.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
|
||||
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
|
||||
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
|
||||
{{- with $api.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
- name: QUEUE_MODE
|
||||
value: "primary"
|
||||
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
|
||||
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
|
||||
- name: QUEUE_PRIMARY_PROCESSES
|
||||
value: {{ not .Values.worker.enabled | quote }}
|
||||
+12
-11
@@ -1,11 +1,12 @@
|
||||
{{- if .Values.server.ingress.enabled }}
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if $api.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-server-ingress
|
||||
{{- if .Values.server.ingress.annotations }}
|
||||
name: checkmate-api-ingress
|
||||
{{- if $api.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.server.ingress.annotations }}
|
||||
{{- range $key, $value := $api.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -17,22 +18,22 @@ metadata:
|
||||
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.server.ingress.className }}
|
||||
{{- if .Values.server.ingress.tls.enabled }}
|
||||
ingressClassName: {{ $api.ingress.className }}
|
||||
{{- if $api.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.server.ingress.host }}
|
||||
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
|
||||
- {{ $api.ingress.host }}
|
||||
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.server.ingress.host }}
|
||||
- host: {{ $api.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-server
|
||||
name: checkmate-api
|
||||
port:
|
||||
number: {{ .Values.server.port }}
|
||||
number: {{ $api.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-api
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-api
|
||||
ports:
|
||||
- port: {{ $api.port }}
|
||||
+5
-3
@@ -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
|
||||
File renamed without changes.
+5
-4
@@ -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;
|
||||
File renamed without changes.
File renamed without changes.
+5
-2
@@ -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:
|
||||
@@ -1,14 +1,15 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if eq .Values.client.ingress.host "change_me" }}
|
||||
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if eq .Values.server.ingress.host "change_me" }}
|
||||
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- if eq $api.ingress.host "change_me" }}
|
||||
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $serverProtocol := .Values.server.protocol }}
|
||||
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
|
||||
{{- fail "server.protocol must be either 'http' or 'https'" }}
|
||||
{{- $apiProtocol := $api.protocol }}
|
||||
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
|
||||
{{- fail "api.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $clientProtocol := .Values.client.protocol }}
|
||||
@@ -21,28 +22,33 @@
|
||||
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
|
||||
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
|
||||
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
|
||||
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
||||
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
|
||||
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
|
||||
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
|
||||
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
||||
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
||||
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
|
||||
{{- $aAnn := $api.ingress.annotations | default dict }}
|
||||
|
||||
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
||||
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
|
||||
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
|
||||
|
||||
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
||||
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
|
||||
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
|
||||
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
|
||||
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
|
||||
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
|
||||
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
|
||||
@@ -1,11 +0,0 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -1,33 +0,0 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: {{ .Values.redis.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: checkmate-redis-persistent-storage
|
||||
spec:
|
||||
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
|
||||
{{- end }}
|
||||
@@ -4,6 +4,14 @@
|
||||
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- /*
|
||||
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
|
||||
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
|
||||
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
|
||||
the one connection string resolves from every namespace. External/already-qualified hosts are not
|
||||
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
|
||||
*/}}
|
||||
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -11,5 +19,9 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $key, $value := $secrets }}
|
||||
{{- if eq $key "DB_CONNECTION_STRING" }}
|
||||
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
|
||||
{{- else }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-server
|
||||
spec:
|
||||
{{- with .Values.server.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.server.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: {{ .Values.server.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
{{- with .Values.server.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-server
|
||||
ports:
|
||||
- port: {{ .Values.server.port }}
|
||||
@@ -0,0 +1,66 @@
|
||||
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-worker
|
||||
spec:
|
||||
{{- if not .Values.worker.autoscaling.enabled }}
|
||||
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-worker
|
||||
spec:
|
||||
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
|
||||
{{- with .Values.worker.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker
|
||||
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
|
||||
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: {{ .Values.worker.healthPort }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
|
||||
env:
|
||||
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
- name: QUEUE_MODE
|
||||
value: "worker"
|
||||
- name: HEALTH_PORT
|
||||
value: {{ .Values.worker.healthPort | quote }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: health
|
||||
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: health
|
||||
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
|
||||
apiVersion: keda.sh/v1alpha1
|
||||
kind: ScaledObject
|
||||
metadata:
|
||||
name: checkmate-worker
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
name: checkmate-worker
|
||||
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
|
||||
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
|
||||
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
|
||||
advanced:
|
||||
horizontalPodAutoscalerConfig:
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
|
||||
triggers:
|
||||
- type: mongodb
|
||||
metadata:
|
||||
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
|
||||
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
|
||||
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
|
||||
query: >-
|
||||
{ "type": "check", "isActive": true,
|
||||
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
|
||||
"$or": [ { "lockedUntil": null },
|
||||
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
|
||||
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
|
||||
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
|
||||
{{- end }}
|
||||
@@ -1,5 +1,7 @@
|
||||
client:
|
||||
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
|
||||
image: ghcr.io/bluewave-labs/checkmate-client
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 80
|
||||
protocol: http
|
||||
ingress:
|
||||
@@ -17,10 +19,24 @@ client:
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
server:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
|
||||
api:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 52345
|
||||
protocol: http
|
||||
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
|
||||
probes:
|
||||
liveness:
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readiness:
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
@@ -31,40 +47,62 @@ server:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
|
||||
# Note: when enabling TLS, also set server.protocol: https and add
|
||||
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
|
||||
# Note: when enabling TLS, also set api.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
image: redis:7.2
|
||||
port: 6379
|
||||
|
||||
mongodb:
|
||||
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
|
||||
image: ghcr.io/bluewave-labs/checkmate-mongo
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 27017
|
||||
|
||||
worker:
|
||||
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
|
||||
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
|
||||
# API is all-in-one and processes jobs itself.
|
||||
enabled: false
|
||||
image: "" # defaults to api.image when empty, same as API image
|
||||
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
|
||||
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
|
||||
replicas: 1 # used only when autoscaling.enabled = false
|
||||
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
|
||||
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
|
||||
resources: {}
|
||||
affinity: {}
|
||||
tolerations: {}
|
||||
probes:
|
||||
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readiness: # /readyz — flips to 503 on drain
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
autoscaling:
|
||||
enabled: false # requires the KEDA operator installed in-cluster
|
||||
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
|
||||
maxReplicaCount: 10
|
||||
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
|
||||
cooldownPeriod: 300 # seconds before scaling an idle target down
|
||||
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
|
||||
mongo:
|
||||
dbName: uptime_db # must match DB_CONNECTION_STRING's database
|
||||
collection: jobs
|
||||
|
||||
secrets:
|
||||
JWT_SECRET: change_me
|
||||
# REFRESH_TOKEN_SECRET: change_me
|
||||
# SYSTEM_EMAIL_ADDRESS: test@example.com
|
||||
# SYSTEM_EMAIL_PASSWORD: change_me
|
||||
# SYSTEM_EMAIL_HOST: smtp.example.com
|
||||
# SYSTEM_EMAIL_PORT: "587"
|
||||
# PAGESPEED_API_KEY: change_me
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
|
||||
CLIENT_HOST: change_me
|
||||
# REDIS_HOST: redis
|
||||
# REDIS_PORT: "6379"
|
||||
# DB_TYPE: MongoDB
|
||||
# TOKEN_TTL: 99d
|
||||
# REFRESH_TOKEN_TTL: 99d
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: info
|
||||
TOKEN_TTL: 99d
|
||||
|
||||
persistence:
|
||||
mongo:
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
redis:
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
Generated
+22
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.8.1",
|
||||
"version": "3.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.8.1",
|
||||
"version": "3.9.1",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
@@ -303,6 +304,25 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.8.1",
|
||||
"version": "3.9.1",
|
||||
"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",
|
||||
|
||||
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
height: props.multiline ? "auto" : 34,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||
import { useParams, useLocation, useNavigate } from "react-router";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -41,8 +42,11 @@ import {
|
||||
type Monitor,
|
||||
type MonitorType,
|
||||
type GamesMap,
|
||||
type HttpMethod,
|
||||
supportsGeoCheck,
|
||||
DefaultPageSpeedStrategy,
|
||||
DefaultHttpMethod,
|
||||
HttpMethods,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
@@ -258,7 +262,7 @@ const CreateMonitorPage = () => {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const { control, watch, handleSubmit, clearErrors, trigger, reset } = form;
|
||||
const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
@@ -270,6 +274,7 @@ const CreateMonitorPage = () => {
|
||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||
|
||||
@@ -1091,27 +1096,144 @@ const CreateMonitorPage = () => {
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="useAdvancedMatching"
|
||||
name="method"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
<Typography>
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? DefaultHttpMethod}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as HttpMethod;
|
||||
field.onChange(value);
|
||||
// HEAD has no response body, reset advanced matching fields if selected
|
||||
if (value === "HEAD") {
|
||||
setValue("useAdvancedMatching", false);
|
||||
setValue("matchMethod", "");
|
||||
setValue("expectedValue", "");
|
||||
setValue("jsonPath", "");
|
||||
}
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.advanced.option.method.label"
|
||||
)}
|
||||
>
|
||||
{HttpMethods.map((method) => (
|
||||
<MenuItem
|
||||
key={method}
|
||||
value={method}
|
||||
>
|
||||
{method}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t(
|
||||
"pages.createMonitor.form.advanced.option.advancedMatching.label"
|
||||
`pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}`
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
{watchedUseAdvancedMatching && (
|
||||
<Controller
|
||||
name="customUpCodes"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
|
||||
field.value?.includes(option.id)
|
||||
);
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
autoHighlight
|
||||
options={ALL_HTTP_STATUS_CODES}
|
||||
value={selectedOptions}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
onChange={(
|
||||
_: unknown,
|
||||
newValue: typeof ALL_HTTP_STATUS_CODES
|
||||
) => {
|
||||
field.onChange(newValue?.map((v) => v.id) || []);
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.label"
|
||||
)}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.description"
|
||||
)}
|
||||
</Typography>
|
||||
{selectedOptions.length > 0 && (
|
||||
<Stack
|
||||
flex={1}
|
||||
gap={theme.spacing(SPACING.MD)}
|
||||
width="100%"
|
||||
>
|
||||
{selectedOptions.map((option, index) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
key={option.id}
|
||||
width="100%"
|
||||
>
|
||||
<Typography flexGrow={1}>{option.name}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value?.filter((id) => id !== option.id)
|
||||
);
|
||||
}}
|
||||
aria-label={t(
|
||||
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
|
||||
)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
{index < selectedOptions.length - 1 && <Divider />}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{watchedMethod !== "HEAD" && (
|
||||
<Controller
|
||||
name="useAdvancedMatching"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
<Typography>
|
||||
{t(
|
||||
"pages.createMonitor.form.advanced.option.advancedMatching.label"
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{watchedUseAdvancedMatching && watchedMethod !== "HEAD" && (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="matchMethod"
|
||||
|
||||
@@ -33,6 +33,26 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
|
||||
subtitle={mongoStats.readyState.toString()}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
|
||||
subtitle={mongoStats.readsPerSecond.toFixed(0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
|
||||
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
|
||||
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
|
||||
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
|
||||
subtitle={mongoStats.writesPerSecond.toFixed(0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.logs.diagnostics.mongoDBStats.host")}
|
||||
subtitle={mongoStats.host}
|
||||
|
||||
@@ -42,12 +42,15 @@ export const TableJobs = ({
|
||||
maxWidth: 220,
|
||||
};
|
||||
|
||||
const isRunning = (row: QueueJobWithId) =>
|
||||
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
|
||||
|
||||
const headers: Header<QueueJobWithId>[] = [
|
||||
{
|
||||
id: "state",
|
||||
content: t("pages.logs.table.headers.state"),
|
||||
render: (row) => {
|
||||
if (row.lockedAt) {
|
||||
if (isRunning(row)) {
|
||||
return t("common.labels.running");
|
||||
}
|
||||
if (row.monitorActive === true) {
|
||||
@@ -72,18 +75,6 @@ export const TableJobs = ({
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "url",
|
||||
content: t("common.table.headers.url"),
|
||||
render: (row) => (
|
||||
<Typography
|
||||
title={row.monitorUrl ?? ""}
|
||||
sx={cellSx}
|
||||
>
|
||||
{row.monitorUrl}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
content: t("common.table.headers.type"),
|
||||
@@ -92,50 +83,36 @@ export const TableJobs = ({
|
||||
{
|
||||
id: "interval",
|
||||
content: t("common.table.headers.interval"),
|
||||
render: (row) => {
|
||||
let interval;
|
||||
if (row.monitorId.toString().includes("geo")) {
|
||||
interval = row.monitorGeoInterval ?? 0;
|
||||
} else {
|
||||
interval = row.repeat ?? 0;
|
||||
}
|
||||
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
|
||||
},
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
content: t("pages.logs.table.headers.lastRunAt"),
|
||||
id: "lastFinishedAt",
|
||||
content: t("pages.logs.table.headers.lastFinishedAt"),
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
||||
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-";
|
||||
return (
|
||||
<Typography
|
||||
title={v}
|
||||
title={lastFinishedAt}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
{lastFinishedAt}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRunTook",
|
||||
content: t("pages.logs.table.headers.lastRunTook"),
|
||||
id: "nextScheduledAt",
|
||||
content: t("pages.logs.table.headers.nextScheduledAt"),
|
||||
render: (row) => {
|
||||
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
|
||||
return <Typography sx={cellSx}>{value}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lockedAt",
|
||||
content: t("pages.logs.table.headers.lockedAt"),
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lockedAt) ?? "-";
|
||||
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
|
||||
return (
|
||||
<Typography
|
||||
title={v}
|
||||
title={nextScheduledAt}
|
||||
sx={cellSx}
|
||||
>
|
||||
{v}
|
||||
{nextScheduledAt}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
@@ -169,7 +146,7 @@ export const TableJobs = ({
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
...(isRunning(row) && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
|
||||
@@ -151,12 +151,18 @@ const CreateStatusPage = () => {
|
||||
fd.append("isPublished", String(data.isPublished));
|
||||
if (data.companyName) fd.append("companyName", data.companyName);
|
||||
if (data.url) fd.append("url", data.url);
|
||||
if (data.customDomain !== undefined && data.customDomain !== null) {
|
||||
fd.append("customDomain", data.customDomain);
|
||||
} else {
|
||||
fd.append("customDomain", "");
|
||||
}
|
||||
if (data.timezone) fd.append("timezone", data.timezone);
|
||||
if (data.color) fd.append("color", data.color);
|
||||
fd.append("showCharts", String(data.showCharts));
|
||||
fd.append("showUptimePercentage", String(data.showUptimePercentage));
|
||||
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
|
||||
fd.append("showInfrastructure", String(data.showInfrastructure));
|
||||
fd.append("customCSS", data.customCSS ?? "");
|
||||
if (data.theme) fd.append("theme", data.theme);
|
||||
if (data.themeMode) fd.append("themeMode", data.themeMode);
|
||||
|
||||
@@ -284,6 +290,27 @@ const CreateStatusPage = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="customDomain"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
||||
)}
|
||||
helperText={
|
||||
fieldState.error?.message ||
|
||||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
@@ -504,6 +531,33 @@ const CreateStatusPage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.customCSS.title")}
|
||||
subtitle={t("pages.statusPages.form.customCSS.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="customCSS"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
multiline
|
||||
rows={8}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.customCSS.option.customCSS.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
import { type StatusPage } from "@/Types/StatusPage";
|
||||
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
|
||||
|
||||
interface HeaderStatusPageControlsProps {
|
||||
isAdmin: boolean;
|
||||
@@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({
|
||||
<Typography
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
|
||||
getStatusPagePublicUrl(statusPage),
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,6 +15,12 @@ import {
|
||||
type StatusPageResponse,
|
||||
type StatusPageTheme,
|
||||
} from "@/Types/StatusPage";
|
||||
import {
|
||||
buildStatusPageApiPath,
|
||||
getStatusPagePreviewUrl,
|
||||
getStatusPagePublicUrl,
|
||||
isCustomDomainHost,
|
||||
} from "@/Utils/statusPageUrl";
|
||||
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
|
||||
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import {
|
||||
@@ -33,6 +40,7 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
|
||||
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
|
||||
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
|
||||
import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles";
|
||||
|
||||
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
|
||||
refined: {
|
||||
@@ -58,6 +66,11 @@ const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
|
||||
HeroSlot: EditorialHero,
|
||||
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
|
||||
},
|
||||
minimal: {
|
||||
createStyles: minimalStyles,
|
||||
HeaderSlot: RefinedHeader,
|
||||
HeroSlot: RefinedHero,
|
||||
},
|
||||
};
|
||||
|
||||
const StatusPageView = () => {
|
||||
@@ -67,9 +80,14 @@ const StatusPageView = () => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const location = useLocation();
|
||||
|
||||
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||
const onCustomDomainHost = isCustomDomainHost();
|
||||
const isPublic =
|
||||
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||
|
||||
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
|
||||
const apiUrl = buildStatusPageApiPath({
|
||||
url,
|
||||
useCustomDomain: onCustomDomainHost,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
||||
apiUrl,
|
||||
@@ -111,17 +129,9 @@ const StatusPageView = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
|
||||
const themedRenderer = (
|
||||
<BaseStatusPage
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
config={themeConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
// Public route: render directly on the viewport, themed background covers everything.
|
||||
if (isPublic) {
|
||||
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
|
||||
return (
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
@@ -129,12 +139,17 @@ const StatusPageView = () => {
|
||||
timezone={statusPage.timezone}
|
||||
paintBody
|
||||
>
|
||||
{themedRenderer}
|
||||
<BaseStatusPage
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
config={themeConfig}
|
||||
/>
|
||||
</StatusPageThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
const publicUrl = getStatusPagePublicUrl(statusPage);
|
||||
const previewUrl = getStatusPagePreviewUrl(statusPage);
|
||||
return (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
@@ -153,7 +168,16 @@ const StatusPageView = () => {
|
||||
timezone={statusPage.timezone}
|
||||
transparent
|
||||
>
|
||||
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
||||
<BrowserFrame url={publicUrl}>
|
||||
<Box
|
||||
component="iframe"
|
||||
src={previewUrl}
|
||||
title={t("pages.statusPages.preview.title")}
|
||||
flex={1}
|
||||
width="100%"
|
||||
border={0}
|
||||
/>
|
||||
</BrowserFrame>
|
||||
</StatusPageThemeProvider>
|
||||
</BasePage>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => {
|
||||
{url}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -99,9 +99,9 @@ export const boldStyles = (
|
||||
},
|
||||
|
||||
top: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: "36px",
|
||||
},
|
||||
brand: {
|
||||
@@ -130,7 +130,7 @@ export const boldStyles = (
|
||||
hero: {
|
||||
position: "relative",
|
||||
borderRadius: "22px",
|
||||
padding: "36px 36px 32px",
|
||||
padding: { xs: "24px 20px", md: "36px 36px 32px" },
|
||||
mb: "28px",
|
||||
background: isDark ? heroBgDark : heroBgLight,
|
||||
border: `1px solid ${tokens.border}`,
|
||||
@@ -149,13 +149,14 @@ export const boldStyles = (
|
||||
},
|
||||
heroTitle: {
|
||||
m: 0,
|
||||
fontSize: 34,
|
||||
fontSize: { xs: 24, md: 34 },
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
color: tokens.text,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
flexWrap: "wrap",
|
||||
gap: "10px",
|
||||
},
|
||||
heroCheck: (tone) => ({
|
||||
@@ -171,9 +172,15 @@ export const boldStyles = (
|
||||
}),
|
||||
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
|
||||
|
||||
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
|
||||
chartSwitchWrap: {
|
||||
display: "flex",
|
||||
justifyContent: { xs: "center", md: "flex-end" },
|
||||
mb: "16px",
|
||||
},
|
||||
chartSwitch: {
|
||||
display: "inline-flex",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
maxWidth: "100%",
|
||||
border: `1px solid ${tokens.border}`,
|
||||
borderRadius: "999px",
|
||||
background: tokens.surface,
|
||||
@@ -181,16 +188,20 @@ export const boldStyles = (
|
||||
gap: "2px",
|
||||
},
|
||||
chartSwitchButton: (active) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
border: 0,
|
||||
background: active ? tokens.upSoft : "transparent",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 11,
|
||||
padding: "8px 18px",
|
||||
padding: { xs: "8px 6px", md: "8px 18px" },
|
||||
cursor: "pointer",
|
||||
color: active ? tokens.up : tokens.textMuted,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.12em",
|
||||
letterSpacing: { xs: "0.04em", md: "0.12em" },
|
||||
transition: "background 0.2s, color 0.2s",
|
||||
borderRadius: "999px",
|
||||
"&:hover": { color: active ? tokens.up : tokens.text },
|
||||
@@ -217,13 +228,21 @@ export const boldStyles = (
|
||||
},
|
||||
cardRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
gap: "24px",
|
||||
alignItems: { xs: "start", md: "center" },
|
||||
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
|
||||
gridTemplateAreas: {
|
||||
xs: `"name" "status" "meta"`,
|
||||
md: `"name name" "meta status"`,
|
||||
},
|
||||
columnGap: { md: "24px" },
|
||||
rowGap: { xs: "10px", md: "6px" },
|
||||
p: "22px 28px",
|
||||
},
|
||||
cardLeft: { minWidth: 0 },
|
||||
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
|
||||
monitorName: {
|
||||
gridArea: "name",
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" },
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
letterSpacing: "-0.01em",
|
||||
@@ -233,10 +252,9 @@ export const boldStyles = (
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
monitorMeta: {
|
||||
display: "flex",
|
||||
gridArea: "meta",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
mt: "6px",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
pill: {
|
||||
@@ -273,6 +291,8 @@ export const boldStyles = (
|
||||
},
|
||||
|
||||
badge: (tone) => ({
|
||||
gridArea: "status",
|
||||
justifySelf: "start",
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
padding: "6px 14px",
|
||||
@@ -296,10 +316,10 @@ export const boldStyles = (
|
||||
}),
|
||||
|
||||
heatmap: {
|
||||
padding: "0 28px 22px",
|
||||
padding: { xs: "0 12px 18px", md: "0 28px 22px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 48,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
@@ -312,10 +332,10 @@ export const boldStyles = (
|
||||
}),
|
||||
|
||||
histogram: {
|
||||
padding: "0 28px",
|
||||
padding: { xs: "0 12px", md: "0 28px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
alignItems: "flex-end",
|
||||
height: 48,
|
||||
},
|
||||
|
||||
@@ -90,9 +90,9 @@ export const editorialStyles = (
|
||||
},
|
||||
|
||||
top: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
alignItems: { xs: "flex-start", md: "flex-end" },
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: "48px",
|
||||
borderBottom: `2px solid ${tokens.text}`,
|
||||
pb: "18px",
|
||||
@@ -119,7 +119,7 @@ export const editorialStyles = (
|
||||
},
|
||||
|
||||
statusLine: {
|
||||
fontSize: 22,
|
||||
fontSize: { xs: 18, md: 22 },
|
||||
fontWeight: 400,
|
||||
m: 0,
|
||||
mb: "8px",
|
||||
@@ -144,26 +144,32 @@ export const editorialStyles = (
|
||||
letterSpacing: "0.12em",
|
||||
color: tokens.textMuted,
|
||||
m: 0,
|
||||
mb: "56px",
|
||||
mb: { xs: "32px", md: "56px" },
|
||||
},
|
||||
|
||||
chartSwitchWrap: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: { xs: "center", md: "flex-end" },
|
||||
mb: "20px",
|
||||
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
|
||||
},
|
||||
chartSwitch: {
|
||||
display: "inline-flex",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
maxWidth: "100%",
|
||||
border: `1px solid ${tokens.text}`,
|
||||
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
|
||||
},
|
||||
chartSwitchButton: (active) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
padding: "6px 14px",
|
||||
letterSpacing: { xs: "0.05em", md: "0.2em" },
|
||||
padding: { xs: "6px 6px", md: "6px 14px" },
|
||||
border: 0,
|
||||
background: active ? tokens.text : "transparent",
|
||||
color: active ? tokens.bg : tokens.text,
|
||||
@@ -188,12 +194,20 @@ export const editorialStyles = (
|
||||
},
|
||||
cardRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "baseline",
|
||||
gap: "20px",
|
||||
alignItems: { xs: "start", md: "baseline" },
|
||||
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
|
||||
gridTemplateAreas: {
|
||||
xs: `"name" "status" "meta"`,
|
||||
md: `"name name" "meta status"`,
|
||||
},
|
||||
columnGap: { md: "20px" },
|
||||
rowGap: { xs: "10px", md: "6px" },
|
||||
},
|
||||
cardLeft: { minWidth: 0 },
|
||||
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
|
||||
monitorName: {
|
||||
gridArea: "name",
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" },
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.015em",
|
||||
@@ -203,14 +217,13 @@ export const editorialStyles = (
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
monitorMeta: {
|
||||
gridArea: "meta",
|
||||
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
|
||||
fontSize: 12,
|
||||
color: tokens.textMuted,
|
||||
mt: "6px",
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
},
|
||||
pill: {
|
||||
textTransform: "uppercase",
|
||||
@@ -234,6 +247,8 @@ export const editorialStyles = (
|
||||
},
|
||||
|
||||
badge: (tone) => ({
|
||||
gridArea: "status",
|
||||
justifySelf: "start",
|
||||
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
@@ -250,7 +265,7 @@ export const editorialStyles = (
|
||||
mt: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "2px",
|
||||
gap: { xs: "1px", md: "2px" },
|
||||
height: 40,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
@@ -262,7 +277,7 @@ export const editorialStyles = (
|
||||
mt: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "2px",
|
||||
gap: { xs: "1px", md: "2px" },
|
||||
alignItems: "flex-end",
|
||||
height: 40,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import type { OverallTone } from "../shared/overallStatus";
|
||||
import { refinedStyles, type RefinedStyles } from "../refined/styles";
|
||||
|
||||
type FlatSx = Exclude<SxProps<Theme>, readonly unknown[] | ((theme: Theme) => unknown)>;
|
||||
|
||||
const extend = (base: SxProps<Theme>, overrides: SxProps<Theme>): SxProps<Theme> => [
|
||||
base as FlatSx,
|
||||
overrides as FlatSx,
|
||||
];
|
||||
|
||||
export const minimalStyles = (
|
||||
tokens: StatusPageThemeTokens,
|
||||
isDark: boolean
|
||||
): RefinedStyles => {
|
||||
const base = refinedStyles(tokens, isDark);
|
||||
|
||||
return {
|
||||
...base,
|
||||
page: extend(base.page, {
|
||||
maxWidth: 820,
|
||||
p: { xs: "32px 16px 64px", md: "48px 20px 72px" },
|
||||
}),
|
||||
top: extend(base.top, {
|
||||
mb: "44px",
|
||||
pb: "16px",
|
||||
borderBottom: `1px solid ${tokens.border}`,
|
||||
}),
|
||||
logoMono: extend(base.logoMono, {
|
||||
borderRadius: "50%",
|
||||
background: tokens.text,
|
||||
}),
|
||||
hero: extend(base.hero, {
|
||||
alignItems: "center",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
boxShadow: "none",
|
||||
p: 0,
|
||||
mb: "40px",
|
||||
}),
|
||||
heroRow: extend(base.heroRow, { justifyContent: "center" }),
|
||||
heroTitle: extend(base.heroTitle, {
|
||||
flex: "initial",
|
||||
fontSize: { xs: 22, md: 28 },
|
||||
fontWeight: 650,
|
||||
textAlign: "center",
|
||||
}),
|
||||
heroIcon: (_tone: OverallTone) => ({ display: "none" }),
|
||||
heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }),
|
||||
chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }),
|
||||
card: extend(base.card, {
|
||||
boxShadow: "none",
|
||||
transition: "none",
|
||||
"&:hover": { transform: "none", boxShadow: "none" },
|
||||
}),
|
||||
cardRow: extend(base.cardRow, { p: "14px 16px" }),
|
||||
badge: (tone) =>
|
||||
extend(base.badge(tone), {
|
||||
background: "transparent",
|
||||
p: 0,
|
||||
}),
|
||||
gauge: extend(base.gauge, { borderRadius: tokens.radius }),
|
||||
footer: extend(base.footer, { mt: "48px", fontSize: 11 }),
|
||||
};
|
||||
};
|
||||
@@ -11,24 +11,24 @@ export const ModernHero = ({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.pulse(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box sx={styles.heroRow}>
|
||||
<Box sx={styles.pulse(overall.tone)} />
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export interface ModernStyles {
|
||||
logoImg: SxProps<Theme>;
|
||||
hero: SxProps<Theme>;
|
||||
pulse: (tone: OverallTone) => SxProps<Theme>;
|
||||
statusCopy: SxProps<Theme>;
|
||||
heroRow: SxProps<Theme>;
|
||||
heroTitle: SxProps<Theme>;
|
||||
heroSub: SxProps<Theme>;
|
||||
heroIcon: (tone: OverallTone) => SxProps<Theme>;
|
||||
@@ -117,9 +117,9 @@ export const modernStyles = (
|
||||
},
|
||||
|
||||
top: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: "32px",
|
||||
},
|
||||
brand: {
|
||||
@@ -148,11 +148,11 @@ export const modernStyles = (
|
||||
hero: {
|
||||
position: "relative",
|
||||
borderRadius: "20px",
|
||||
padding: "28px 32px",
|
||||
padding: { xs: "20px", md: "28px 32px" },
|
||||
mb: "24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
background: tokens.surface,
|
||||
border: `1px solid ${tokens.border}`,
|
||||
boxShadow: cardShadow,
|
||||
@@ -182,25 +182,37 @@ export const modernStyles = (
|
||||
},
|
||||
};
|
||||
},
|
||||
statusCopy: { flex: 1, minWidth: 0 },
|
||||
heroRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: { xs: "14px", md: "20px" },
|
||||
},
|
||||
heroTitle: {
|
||||
m: 0,
|
||||
mb: "4px",
|
||||
fontSize: 22,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: { xs: 18, md: 22 },
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
color: tokens.text,
|
||||
},
|
||||
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
|
||||
heroIcon: (tone) => ({
|
||||
display: { xs: "none", md: "flex" },
|
||||
color: toneColor(tone, tokens),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
}),
|
||||
|
||||
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
|
||||
chartSwitchWrap: {
|
||||
display: "flex",
|
||||
justifyContent: { xs: "center", md: "flex-end" },
|
||||
mb: "14px",
|
||||
},
|
||||
chartSwitch: {
|
||||
display: "inline-flex",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
maxWidth: "100%",
|
||||
border: `1px solid ${tokens.border}`,
|
||||
borderRadius: "999px",
|
||||
background: tokens.surface,
|
||||
@@ -208,16 +220,20 @@ export const modernStyles = (
|
||||
gap: "2px",
|
||||
},
|
||||
chartSwitchButton: (active) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
border: 0,
|
||||
background: active ? tokens.upSoft : "transparent",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 11,
|
||||
padding: "6px 16px",
|
||||
padding: { xs: "6px 6px", md: "6px 16px" },
|
||||
cursor: "pointer",
|
||||
color: active ? tokens.up : tokens.textMuted,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
letterSpacing: { xs: "0.04em", md: "0.08em" },
|
||||
transition: "background 0.2s, color 0.2s",
|
||||
borderRadius: "999px",
|
||||
"&:hover": { color: active ? tokens.up : tokens.text },
|
||||
@@ -256,13 +272,21 @@ export const modernStyles = (
|
||||
},
|
||||
cardRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
alignItems: { xs: "start", md: "center" },
|
||||
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
|
||||
gridTemplateAreas: {
|
||||
xs: `"name" "status" "meta"`,
|
||||
md: `"name name" "meta status"`,
|
||||
},
|
||||
columnGap: { md: "20px" },
|
||||
rowGap: { xs: "10px", md: "6px" },
|
||||
p: "18px 24px",
|
||||
},
|
||||
cardLeft: { minWidth: 0 },
|
||||
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
|
||||
monitorName: {
|
||||
gridArea: "name",
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" },
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
letterSpacing: "-0.005em",
|
||||
@@ -272,10 +296,9 @@ export const modernStyles = (
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
monitorMeta: {
|
||||
display: "flex",
|
||||
gridArea: "meta",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
mt: "6px",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
pill: {
|
||||
@@ -299,6 +322,8 @@ export const modernStyles = (
|
||||
},
|
||||
|
||||
badge: (tone) => ({
|
||||
gridArea: "status",
|
||||
justifySelf: "start",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: "5px 12px",
|
||||
@@ -319,10 +344,10 @@ export const modernStyles = (
|
||||
}),
|
||||
|
||||
heatmap: {
|
||||
padding: "0 24px 20px",
|
||||
padding: { xs: "0 12px 16px", md: "0 24px 20px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 46,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
@@ -334,10 +359,10 @@ export const modernStyles = (
|
||||
}),
|
||||
|
||||
histogram: {
|
||||
padding: "0 24px",
|
||||
padding: { xs: "0 12px", md: "0 24px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
alignItems: "flex-end",
|
||||
height: 46,
|
||||
},
|
||||
|
||||
@@ -11,24 +11,24 @@ export const RefinedHero = ({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.statusDot(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box sx={styles.heroRow}>
|
||||
<Box sx={styles.statusDot(overall.tone)} />
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export interface RefinedStyles {
|
||||
company: SxProps<Theme>;
|
||||
hero: SxProps<Theme>;
|
||||
statusDot: (tone: OverallTone) => SxProps<Theme>;
|
||||
statusCopy: SxProps<Theme>;
|
||||
heroRow: SxProps<Theme>;
|
||||
heroTitle: SxProps<Theme>;
|
||||
heroSub: SxProps<Theme>;
|
||||
heroIcon: (tone: OverallTone) => SxProps<Theme>;
|
||||
@@ -102,9 +102,9 @@ export const refinedStyles = (
|
||||
},
|
||||
|
||||
top: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: "28px",
|
||||
},
|
||||
brand: {
|
||||
@@ -133,10 +133,10 @@ export const refinedStyles = (
|
||||
background: tokens.surface,
|
||||
border: `1px solid ${tokens.border}`,
|
||||
borderRadius: tokens.radius,
|
||||
padding: "22px 24px",
|
||||
padding: { xs: "18px 20px", md: "22px 24px" },
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
boxShadow: cardShadow,
|
||||
mb: "20px",
|
||||
},
|
||||
@@ -148,10 +148,15 @@ export const refinedStyles = (
|
||||
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
|
||||
flexShrink: 0,
|
||||
}),
|
||||
statusCopy: { flex: 1, minWidth: 0 },
|
||||
heroRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: { xs: "12px", md: "16px" },
|
||||
},
|
||||
heroTitle: {
|
||||
m: 0,
|
||||
mb: "2px",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
@@ -159,14 +164,21 @@ export const refinedStyles = (
|
||||
},
|
||||
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
|
||||
heroIcon: (tone) => ({
|
||||
display: { xs: "none", md: "flex" },
|
||||
color: toneColor(tone, tokens),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
}),
|
||||
|
||||
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
|
||||
chartSwitchWrap: {
|
||||
display: "flex",
|
||||
justifyContent: { xs: "center", md: "flex-end" },
|
||||
mb: "12px",
|
||||
},
|
||||
chartSwitch: {
|
||||
display: "inline-flex",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
maxWidth: "100%",
|
||||
border: `1px solid ${tokens.border}`,
|
||||
borderRadius: "8px",
|
||||
background: tokens.surface,
|
||||
@@ -174,11 +186,15 @@ export const refinedStyles = (
|
||||
gap: "2px",
|
||||
},
|
||||
chartSwitchButton: (active) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "center",
|
||||
border: 0,
|
||||
background: active ? tokens.upSoft : "transparent",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 11,
|
||||
padding: "5px 14px",
|
||||
padding: { xs: "5px 6px", md: "5px 14px" },
|
||||
cursor: "pointer",
|
||||
color: active ? tokens.up : tokens.textMuted,
|
||||
borderRadius: "5px",
|
||||
@@ -207,13 +223,21 @@ export const refinedStyles = (
|
||||
},
|
||||
cardRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
alignItems: { xs: "start", md: "center" },
|
||||
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
|
||||
gridTemplateAreas: {
|
||||
xs: `"name" "status" "meta"`,
|
||||
md: `"name name" "meta status"`,
|
||||
},
|
||||
columnGap: { md: "16px" },
|
||||
rowGap: { xs: "10px", md: "4px" },
|
||||
p: "16px 20px",
|
||||
},
|
||||
cardLeft: { minWidth: 0 },
|
||||
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
|
||||
monitorName: {
|
||||
gridArea: "name",
|
||||
minWidth: 0,
|
||||
width: { xs: "100%", md: "auto" },
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
letterSpacing: "-0.005em",
|
||||
@@ -223,10 +247,9 @@ export const refinedStyles = (
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
monitorMeta: {
|
||||
display: "flex",
|
||||
gridArea: "meta",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
mt: "4px",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
pill: {
|
||||
@@ -251,6 +274,8 @@ export const refinedStyles = (
|
||||
},
|
||||
|
||||
badge: (tone) => ({
|
||||
gridArea: "status",
|
||||
justifySelf: "start",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "4px 10px",
|
||||
@@ -261,10 +286,10 @@ export const refinedStyles = (
|
||||
}),
|
||||
|
||||
heatmap: {
|
||||
padding: "0 20px 16px",
|
||||
padding: { xs: "0 10px 14px", md: "0 20px 16px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 42,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
@@ -276,10 +301,10 @@ export const refinedStyles = (
|
||||
}),
|
||||
|
||||
histogram: {
|
||||
padding: "0 20px",
|
||||
padding: { xs: "0 10px", md: "0 20px" },
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gap: "3px",
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
alignItems: "flex-end",
|
||||
height: 42,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
statusBadgeKey,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
@@ -101,8 +102,10 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
|
||||
<Stack
|
||||
component="header"
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
sx={styles.top}
|
||||
>
|
||||
<HeaderSlot
|
||||
@@ -112,7 +115,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
monitorCount={monitors.length}
|
||||
styles={styles}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<HeroSlot
|
||||
statusPage={statusPage}
|
||||
@@ -161,6 +164,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -169,32 +173,39 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
sx={styles.monitorMeta}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ ...styles.pill }}
|
||||
>
|
||||
{uptimePercentage}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
|
||||
@@ -191,9 +191,47 @@ const editorial: ThemeVariants = {
|
||||
},
|
||||
};
|
||||
|
||||
const minimal: ThemeVariants = {
|
||||
light: {
|
||||
bg: "#ffffff",
|
||||
surface: "#ffffff",
|
||||
border: "#dfe3e8",
|
||||
text: "#17191c",
|
||||
textMuted: "#667085",
|
||||
up: "#12a174",
|
||||
upStrong: "#087a57",
|
||||
upSoft: "#e7f7f1",
|
||||
degraded: "#b54708",
|
||||
degradedSoft: "#fff1dc",
|
||||
down: "#c4323f",
|
||||
downSoft: "#fde8ea",
|
||||
warn: "#c27803",
|
||||
warnSoft: "#fff3d6",
|
||||
radius: "6px",
|
||||
},
|
||||
dark: {
|
||||
bg: "#0d0f12",
|
||||
surface: "#111419",
|
||||
border: "#2b3038",
|
||||
text: "#f2f4f7",
|
||||
textMuted: "#98a2b3",
|
||||
up: "#32d6a0",
|
||||
upStrong: "#1eb783",
|
||||
upSoft: "rgba(50,214,160,0.14)",
|
||||
degraded: "#f0a44b",
|
||||
degradedSoft: "rgba(240,164,75,0.16)",
|
||||
down: "#ff6675",
|
||||
downSoft: "rgba(255,102,117,0.16)",
|
||||
warn: "#f2b84b",
|
||||
warnSoft: "rgba(242,184,75,0.16)",
|
||||
radius: "6px",
|
||||
},
|
||||
};
|
||||
|
||||
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
||||
refined,
|
||||
modern,
|
||||
bold,
|
||||
editorial,
|
||||
minimal,
|
||||
};
|
||||
@@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
|
||||
|
||||
import { type StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
interface StatusPagesTableProps {
|
||||
data: StatusPage[];
|
||||
@@ -53,8 +55,7 @@ export const StatusPagesTable = ({
|
||||
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
|
||||
if (row.isPublished) {
|
||||
e.stopPropagation();
|
||||
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +71,9 @@ export const StatusPagesTable = ({
|
||||
content: t("pages.statusPages.table.headers.url"),
|
||||
render: (row) => {
|
||||
const content = row.isPublished
|
||||
? `/${row.url}`
|
||||
? row.customDomain
|
||||
? getStatusPagePublicPath(row)
|
||||
: `/${row.url}`
|
||||
: t("pages.statusPages.table.unpublished");
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -25,6 +25,8 @@ import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
|
||||
interface MonitorTableProps {
|
||||
monitors: Monitor[];
|
||||
@@ -259,6 +261,24 @@ export const MonitorTable = ({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
content: t("pages.uptime.table.headers.uptime"),
|
||||
render: (row) => {
|
||||
const uptimePercentage = row.uptimePercentage ?? 0;
|
||||
const formattedPercentage = formatPercentage(uptimePercentage);
|
||||
const paletteKey = getUptimePercentageColor(uptimePercentage);
|
||||
const color = theme.palette[paletteKey].main;
|
||||
return (
|
||||
<Stack alignItems={"center"}>
|
||||
<ColoredLabel
|
||||
text={formattedPercentage}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
content: t("common.table.headers.tags"),
|
||||
@@ -304,7 +324,9 @@ export const MonitorTable = ({
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return row.type;
|
||||
return row.type === "http" && row.method
|
||||
? `${row.type} (${row.method})`
|
||||
: row.type;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs";
|
||||
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
||||
|
||||
import CreateMonitor from "@/Pages/CreateMonitor";
|
||||
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
||||
|
||||
const Routes = () => {
|
||||
if (isCustomDomainHost()) {
|
||||
return (
|
||||
<LibRoutes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Status />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Status />}
|
||||
/>
|
||||
</LibRoutes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LibRoutes>
|
||||
<Route
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface V8HeapStats {
|
||||
|
||||
export interface MongoStats {
|
||||
readyState: number;
|
||||
readsPerSecond: number;
|
||||
insertsPerSecond: number;
|
||||
updatesPerSecond: number;
|
||||
deletesPerSecond: number;
|
||||
writesPerSecond: number;
|
||||
host: string;
|
||||
port: number;
|
||||
dbName: string;
|
||||
|
||||
@@ -48,6 +48,10 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||
|
||||
export const HttpMethods = ["GET", "HEAD"] as const;
|
||||
export type HttpMethod = (typeof HttpMethods)[number];
|
||||
export const DefaultHttpMethod: HttpMethod = "GET";
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -64,6 +68,7 @@ export interface Monitor {
|
||||
jsonPath?: string;
|
||||
expectedValue?: string;
|
||||
matchMethod?: MonitorMatchMethod;
|
||||
method?: HttpMethod;
|
||||
url: string;
|
||||
port?: number;
|
||||
isActive: boolean;
|
||||
@@ -71,6 +76,7 @@ export interface Monitor {
|
||||
uptimePercentage?: number;
|
||||
notifications: string[];
|
||||
tags: string[];
|
||||
customUpCodes?: number[];
|
||||
secret?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
|
||||
@@ -30,23 +30,16 @@ export type QueueMetrics = {
|
||||
|
||||
export type QueueJobSummary = {
|
||||
monitorId: string | number;
|
||||
monitorUrl: string | null;
|
||||
monitorType: string | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedBy: string | null;
|
||||
lockedUntil: number | null;
|
||||
lockedAt: number | null;
|
||||
nextScheduledAt: number;
|
||||
runCount: number;
|
||||
failCount: number;
|
||||
failReason: string | null;
|
||||
lastRunAt: number | null;
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
};
|
||||
|
||||
export interface QueueData {
|
||||
|
||||
@@ -21,7 +21,13 @@ export const getMonitorTypeLabel = (
|
||||
return key ? t(key) : type;
|
||||
};
|
||||
|
||||
export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
|
||||
export const STATUS_PAGE_THEMES = [
|
||||
"refined",
|
||||
"modern",
|
||||
"bold",
|
||||
"editorial",
|
||||
"minimal",
|
||||
] as const;
|
||||
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
|
||||
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
|
||||
|
||||
@@ -47,6 +53,7 @@ export interface StatusPage {
|
||||
type: MonitorDisplayType[];
|
||||
companyName: string;
|
||||
url: string;
|
||||
customDomain?: string | null;
|
||||
timezone?: string;
|
||||
color: string;
|
||||
monitors: string[];
|
||||
|
||||
@@ -63,6 +63,16 @@ export const getResponseTimeColor = (responseTime: number): PaletteKey => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => {
|
||||
if (uptimePercentage >= 0.75) {
|
||||
return "success";
|
||||
} else if (uptimePercentage >= 0.5) {
|
||||
return "warning";
|
||||
} else {
|
||||
return "error";
|
||||
}
|
||||
};
|
||||
|
||||
export const getInfraGaugeColor = (val: number, theme: any) => {
|
||||
if (val < 50) {
|
||||
return theme.palette.success.main;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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)" },
|
||||
];
|
||||
@@ -0,0 +1,99 @@
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
|
||||
|
||||
const getClientOriginProtocol = (): string => {
|
||||
const source =
|
||||
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
|
||||
if (!source) {
|
||||
return "https:";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(source).protocol;
|
||||
} catch {
|
||||
return "https:";
|
||||
}
|
||||
};
|
||||
|
||||
const buildCustomDomainOrigin = (customDomain: string): string =>
|
||||
`${getClientOriginProtocol()}//${customDomain}`;
|
||||
|
||||
export const getAppHostname = (): string | null => {
|
||||
const source =
|
||||
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(source).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isCustomDomainHost = (): boolean => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appHostname = getAppHostname();
|
||||
if (!appHostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.location.hostname.toLowerCase() !== appHostname;
|
||||
};
|
||||
|
||||
export const getStatusPagePublicPath = (
|
||||
statusPage: Pick<StatusPage, "url" | "customDomain">
|
||||
): string => {
|
||||
if (statusPage.customDomain) {
|
||||
return buildCustomDomainOrigin(statusPage.customDomain);
|
||||
}
|
||||
|
||||
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
};
|
||||
|
||||
export const getStatusPagePublicUrl = (
|
||||
statusPage: Pick<StatusPage, "url" | "customDomain">
|
||||
): string => {
|
||||
if (statusPage.customDomain) {
|
||||
return buildCustomDomainOrigin(statusPage.customDomain);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
}
|
||||
|
||||
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
};
|
||||
|
||||
// Same-origin URL for the admin preview iframe; a custom domain would be cross
|
||||
// origin and blocked by the admin document's CSP frame-src.
|
||||
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
}
|
||||
|
||||
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
};
|
||||
|
||||
export const buildStatusPageApiPath = (options: {
|
||||
url?: string;
|
||||
useCustomDomain?: boolean;
|
||||
}): string | null => {
|
||||
const query = "type=uptime&type=infrastructure";
|
||||
|
||||
if (options.useCustomDomain && typeof window !== "undefined") {
|
||||
const domain = encodeURIComponent(window.location.hostname);
|
||||
return `/status-page/resolve?domain=${domain}&${query}`;
|
||||
}
|
||||
|
||||
if (options.url) {
|
||||
return `/status-page/${options.url}?${query}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "معرّف المراقب",
|
||||
"runCount": "عدد التشغيلات",
|
||||
"failCount": "عدد حالات الفشل",
|
||||
"lastRunAt": "آخر تشغيل في",
|
||||
"lockedAt": "مقفل في",
|
||||
"lastFinishedAt": "آخر انتهاء في",
|
||||
"lastRunTook": "مدة آخر تشغيل",
|
||||
"lastFailedAt": "آخر فشل في",
|
||||
"failReason": "سبب الفشل",
|
||||
"state": "الحالة"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "モニターID",
|
||||
"runCount": "実行回数",
|
||||
"failCount": "失敗回数",
|
||||
"lastRunAt": "最終実行日時",
|
||||
"lockedAt": "ロック日時",
|
||||
"lastFinishedAt": "最終完了日時",
|
||||
"lastRunTook": "最終実行時間",
|
||||
"lastFailedAt": "最終失敗日時",
|
||||
"failReason": "失敗理由",
|
||||
"state": "状態"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "ID монитора",
|
||||
"runCount": "Кол-во запусков",
|
||||
"failCount": "Кол-во ошибок",
|
||||
"lastRunAt": "Последний запуск",
|
||||
"lockedAt": "Заблокировано в",
|
||||
"lastFinishedAt": "Последнее завершение",
|
||||
"lastRunTook": "Последний запуск занял",
|
||||
"lastFailedAt": "Последняя ошибка",
|
||||
"failReason": "Причина ошибки",
|
||||
"state": "Состояние"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "รหัสมอนิเตอร์",
|
||||
"runCount": "จำนวนรัน",
|
||||
"failCount": "จำนวนล้มเหลว",
|
||||
"lastRunAt": "รันล่าสุดเมื่อ",
|
||||
"lockedAt": "ล็อกเมื่อ",
|
||||
"lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ",
|
||||
"lastRunTook": "รันล่าสุดใช้เวลา",
|
||||
"lastFailedAt": "ล้มเหลวล่าสุดเมื่อ",
|
||||
"failReason": "สาเหตุที่ล้มเหลว",
|
||||
"state": "สถานะ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "ID монітора",
|
||||
"runCount": "Кількість запусків",
|
||||
"failCount": "Кількість помилок",
|
||||
"lastRunAt": "Останній запуск",
|
||||
"lockedAt": "Заблоковано о",
|
||||
"lastFinishedAt": "Останнє завершення",
|
||||
"lastRunTook": "Останній запуск тривав",
|
||||
"lastFailedAt": "Остання помилка",
|
||||
"failReason": "Причина помилки",
|
||||
"state": "Стан"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "监控 ID",
|
||||
"runCount": "运行次数",
|
||||
"failCount": "失败次数",
|
||||
"lastRunAt": "上次运行于",
|
||||
"lockedAt": "锁定于",
|
||||
"lastFinishedAt": "上次完成于",
|
||||
"lastRunTook": "上次运行耗时",
|
||||
"lastFailedAt": "上次失败于",
|
||||
"failReason": "失败原因",
|
||||
"state": "状态"
|
||||
|
||||
@@ -941,10 +941,7 @@
|
||||
"monitorId": "監控 ID",
|
||||
"runCount": "執行次數",
|
||||
"failCount": "失敗次數",
|
||||
"lastRunAt": "最後執行於",
|
||||
"lockedAt": "鎖定於",
|
||||
"lastFinishedAt": "最後完成於",
|
||||
"lastRunTook": "最後執行耗時",
|
||||
"lastFailedAt": "最後失敗於",
|
||||
"failReason": "失敗原因",
|
||||
"state": "狀態"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]!
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
FROM redis
|
||||
EXPOSE 6379
|
||||
@@ -19,7 +19,6 @@ services:
|
||||
# Mount the certs directory (read-only for security)
|
||||
- ./certs:/certs:ro
|
||||
depends_on:
|
||||
- redis
|
||||
- mongodb
|
||||
# Optional: Add healthcheck to ensure the server starts properly
|
||||
healthcheck:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,6 @@ services:
|
||||
- UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1
|
||||
- UPTIME_APP_CLIENT_HOST=http://localhost
|
||||
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- CLIENT_HOST=http://localhost
|
||||
- JWT_SECRET=my_secret
|
||||
depends_on:
|
||||
|
||||
+2
-1
@@ -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
|
||||
# ------------------
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ const config: Config = {
|
||||
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
|
||||
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
|
||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ignore": ["node_modules/*"],
|
||||
"watch": ["src/**/*.ts", "src/**/*.js", "*.json"],
|
||||
"watch": ["src/**/*.ts", "src/**/*.js", "*.json", ".env"],
|
||||
"ext": "ts,js,json"
|
||||
}
|
||||
+176
-2
@@ -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"],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getTeamChecksQueryValidation,
|
||||
deleteChecksParamValidation,
|
||||
getChecksSummaryByTeamIdQueryValidation,
|
||||
} from "@/validation/checkValidation.js";
|
||||
} from "@/api/validation/checkValidation.js";
|
||||
|
||||
const tags = ["checks"];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registry } from "../registry.js";
|
||||
import { bearer, okUnknown, standardErrors } from "../helpers.js";
|
||||
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
|
||||
import { getChecksParamValidation, getChecksQueryValidation } from "@/api/validation/checkValidation.js";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { registry } from "../registry.js";
|
||||
import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js";
|
||||
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
|
||||
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/api/validation/incidentValidation.js";
|
||||
|
||||
const tags = ["incidents"];
|
||||
|
||||
|
||||
@@ -1,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"];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
deleteMaintenanceWindowByIdParamValidation,
|
||||
editMaintenanceWindowByIdParamValidation,
|
||||
editMaintenanceByIdWindowBodyValidation,
|
||||
} from "@/validation/maintenanceWindowValidation.js";
|
||||
} from "@/api/validation/maintenanceWindowValidation.js";
|
||||
|
||||
const tags = ["maintenance-window"];
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getNotificationByIdParamValidation,
|
||||
editNotificationParamValidation,
|
||||
testAllNotificationsBodyValidation,
|
||||
} from "@/validation/notificationValidation.js";
|
||||
} from "@/api/validation/notificationValidation.js";
|
||||
|
||||
const tags = ["notifications"];
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Generated
+1216
-988
File diff suppressed because it is too large.
Load diff
+3
-5
@@ -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",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import mongoose from "mongoose";
|
||||
import { MonitorModel } from "../dist/domain/monitors/monitor.model.js";
|
||||
import { UserModel } from "../dist/domain/users/user.model.js";
|
||||
|
||||
// Seeds a large number of HTTP monitors against real, resolving domains so the
|
||||
// scheduler can be exercised under load. Run after `npm run build` (imports the
|
||||
// compiled models from dist/, like scripts/generate-checks.js).
|
||||
//
|
||||
// node scripts/seed-monitors.js # 10k monitors, owner auto-detected
|
||||
// COUNT=2000 INTERVAL=30000 node scripts/seed-monitors.js
|
||||
// CLEAR=true node scripts/seed-monitors.js # remove prior seed monitors first
|
||||
// TEAM_ID=<id> USER_ID=<id> node scripts/seed-monitors.js
|
||||
//
|
||||
// NOTE: the running queue only schedules monitors it finds at init(), so restart
|
||||
// the server after seeding for the new monitors to actually start checking.
|
||||
|
||||
const DEFAULT_COUNT = 500;
|
||||
const DEFAULT_BATCH = 1_000;
|
||||
const DEFAULT_INTERVAL = 60_000;
|
||||
const NAME_PREFIX = "Seed";
|
||||
// Stored in `description` so a re-run with CLEAR=true can find and remove them.
|
||||
const SEED_MARKER = "checkmate-scaling-seed";
|
||||
|
||||
// A spread of well-known, reliably-resolving domains across regions, TLDs and
|
||||
// CDNs. Monitors round-robin through this pool, so the load is split across many
|
||||
// hosts rather than hammering one. Every entry returns a real HTTP response
|
||||
// (some may 403/429 under load — still a valid response for scaling purposes).
|
||||
const DOMAINS = [
|
||||
"google.com",
|
||||
"youtube.com",
|
||||
"facebook.com",
|
||||
"wikipedia.org",
|
||||
"amazon.com",
|
||||
"reddit.com",
|
||||
"x.com",
|
||||
"instagram.com",
|
||||
"linkedin.com",
|
||||
"microsoft.com",
|
||||
"apple.com",
|
||||
"cloudflare.com",
|
||||
"github.com",
|
||||
"gitlab.com",
|
||||
"stackoverflow.com",
|
||||
"netflix.com",
|
||||
"bing.com",
|
||||
"yahoo.com",
|
||||
"duckduckgo.com",
|
||||
"mozilla.org",
|
||||
"wordpress.org",
|
||||
"wordpress.com",
|
||||
"adobe.com",
|
||||
"salesforce.com",
|
||||
"oracle.com",
|
||||
"ibm.com",
|
||||
"intel.com",
|
||||
"nvidia.com",
|
||||
"amd.com",
|
||||
"dell.com",
|
||||
"hp.com",
|
||||
"cisco.com",
|
||||
"vmware.com",
|
||||
"redhat.com",
|
||||
"ubuntu.com",
|
||||
"debian.org",
|
||||
"kernel.org",
|
||||
"python.org",
|
||||
"nodejs.org",
|
||||
"npmjs.com",
|
||||
"docker.com",
|
||||
"kubernetes.io",
|
||||
"golang.org",
|
||||
"rust-lang.org",
|
||||
"php.net",
|
||||
"ruby-lang.org",
|
||||
"java.com",
|
||||
"spring.io",
|
||||
"djangoproject.com",
|
||||
"flask.palletsprojects.com",
|
||||
"vuejs.org",
|
||||
"reactjs.org",
|
||||
"angular.io",
|
||||
"svelte.dev",
|
||||
"jquery.com",
|
||||
"npm.im",
|
||||
"pypi.org",
|
||||
"rubygems.org",
|
||||
"packagist.org",
|
||||
"maven.apache.org",
|
||||
"apache.org",
|
||||
"nginx.org",
|
||||
"postgresql.org",
|
||||
"mysql.com",
|
||||
"mongodb.com",
|
||||
"redis.io",
|
||||
"elastic.co",
|
||||
"grafana.com",
|
||||
"prometheus.io",
|
||||
"hashicorp.com",
|
||||
"digitalocean.com",
|
||||
"linode.com",
|
||||
"vultr.com",
|
||||
"heroku.com",
|
||||
"netlify.com",
|
||||
"vercel.com",
|
||||
"render.com",
|
||||
"fly.io",
|
||||
"railway.app",
|
||||
"cloudinary.com",
|
||||
"twilio.com",
|
||||
"stripe.com",
|
||||
"paypal.com",
|
||||
"shopify.com",
|
||||
"squarespace.com",
|
||||
"wix.com",
|
||||
"godaddy.com",
|
||||
"namecheap.com",
|
||||
"name.com",
|
||||
"gandi.net",
|
||||
"bbc.co.uk",
|
||||
"theguardian.com",
|
||||
"nytimes.com",
|
||||
"washingtonpost.com",
|
||||
"cnn.com",
|
||||
"reuters.com",
|
||||
"bloomberg.com",
|
||||
"forbes.com",
|
||||
"wsj.com",
|
||||
"economist.com",
|
||||
"nature.com",
|
||||
"sciencedirect.com",
|
||||
"arxiv.org",
|
||||
"mit.edu",
|
||||
"stanford.edu",
|
||||
"harvard.edu",
|
||||
"berkeley.edu",
|
||||
"ox.ac.uk",
|
||||
"cam.ac.uk",
|
||||
"ethz.ch",
|
||||
"spotify.com",
|
||||
"soundcloud.com",
|
||||
"twitch.tv",
|
||||
"vimeo.com",
|
||||
"dailymotion.com",
|
||||
"imdb.com",
|
||||
"wikimedia.org",
|
||||
"archive.org",
|
||||
"ietf.org",
|
||||
"w3.org",
|
||||
"who.int",
|
||||
"un.org",
|
||||
"europa.eu",
|
||||
"gov.uk",
|
||||
"canada.ca",
|
||||
"alibaba.com",
|
||||
"aliexpress.com",
|
||||
"baidu.com",
|
||||
"tencent.com",
|
||||
"naver.com",
|
||||
"rakuten.co.jp",
|
||||
"yandex.com",
|
||||
"ozon.ru",
|
||||
"mercadolibre.com",
|
||||
"flipkart.com",
|
||||
"booking.com",
|
||||
"airbnb.com",
|
||||
"expedia.com",
|
||||
"tripadvisor.com",
|
||||
"uber.com",
|
||||
"dropbox.com",
|
||||
"box.com",
|
||||
"atlassian.com",
|
||||
"trello.com",
|
||||
"notion.so",
|
||||
"slack.com",
|
||||
"zoom.us",
|
||||
"asana.com",
|
||||
"figma.com",
|
||||
"canva.com",
|
||||
];
|
||||
|
||||
const parseObjectId = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return new mongoose.Types.ObjectId(value);
|
||||
} catch {
|
||||
console.warn(`Ignoring invalid ObjectId '${value}'`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Monitors need a real teamId/userId to show up for an account and to be picked
|
||||
// up by the queue. Prefer explicit env ids, otherwise derive from the oldest
|
||||
// existing user (the typical single-tenant dev setup).
|
||||
async function resolveOwner() {
|
||||
const teamId = parseObjectId(process.env.TEAM_ID);
|
||||
const userId = parseObjectId(process.env.USER_ID);
|
||||
if (teamId && userId) return { teamId, userId };
|
||||
|
||||
const user = await UserModel.findOne(userId ? { _id: userId } : {}).sort({ createdAt: 1 });
|
||||
if (!user?.teamId) {
|
||||
throw new Error(
|
||||
"Could not determine an owner: no existing user with a teamId. " + "Create an account first, or pass TEAM_ID and USER_ID explicitly."
|
||||
);
|
||||
}
|
||||
return { teamId: teamId ?? user.teamId, userId: user._id };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db";
|
||||
const total = Number(process.env.COUNT ?? DEFAULT_COUNT);
|
||||
const batchSize = Number(process.env.BATCH_SIZE ?? DEFAULT_BATCH);
|
||||
const interval = Number(process.env.INTERVAL ?? DEFAULT_INTERVAL);
|
||||
|
||||
console.log(`Connecting to MongoDB at ${mongoUri}`);
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
const { teamId, userId } = await resolveOwner();
|
||||
|
||||
if (process.env.CLEAR === "true") {
|
||||
const { deletedCount } = await MonitorModel.deleteMany({ description: SEED_MARKER });
|
||||
console.log(`Removed ${deletedCount} previously seeded monitors`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Seeding ${total} http monitors for team ${teamId} (user ${userId}) across ${DOMAINS.length} domains, ` +
|
||||
`interval ${interval}ms, in batches of ${batchSize}`
|
||||
);
|
||||
|
||||
const docs = [];
|
||||
const startTime = Date.now();
|
||||
let inserted = 0;
|
||||
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
const domain = DOMAINS[i % DOMAINS.length];
|
||||
docs.push({
|
||||
userId,
|
||||
teamId,
|
||||
name: `${NAME_PREFIX} ${String(i + 1).padStart(5, "0")} ${domain}`,
|
||||
description: SEED_MARKER,
|
||||
type: "http",
|
||||
url: `https://${domain}`,
|
||||
isActive: true,
|
||||
interval,
|
||||
lastEvaluatedAt: 0, // evaluator watermark; without it findUnevaluatedByMonitorId gets new Date(undefined)
|
||||
});
|
||||
|
||||
if (docs.length === batchSize) {
|
||||
await MonitorModel.insertMany(docs, { ordered: false });
|
||||
inserted += docs.length;
|
||||
console.log(`Inserted ${inserted} / ${total}`);
|
||||
docs.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (docs.length > 0) {
|
||||
await MonitorModel.insertMany(docs, { ordered: false });
|
||||
inserted += docs.length;
|
||||
}
|
||||
|
||||
await mongoose.disconnect();
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
console.log(`Finished inserting ${inserted} monitors in ${duration}s`);
|
||||
console.log("Restart the server so the queue schedules the new monitors.");
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Failed to seed monitors", error);
|
||||
process.exit(1);
|
||||
});
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
|
||||
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/api/controllers/controllerUtils.js";
|
||||
|
||||
import {
|
||||
registrationBodyValidation,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
recoveryValidation,
|
||||
recoveryTokenBodyValidation,
|
||||
newPasswordValidation,
|
||||
} from "@/validation/authValidation.js";
|
||||
} from "@/api/validation/authValidation.js";
|
||||
|
||||
import {
|
||||
editUserBodyValidation,
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
editUserByIdBodyValidation,
|
||||
editSuperadminUserByIdBodyValidation,
|
||||
editUserPasswordByIdBodyValidation,
|
||||
} from "@/validation/userValidation.js";
|
||||
import { IUserService } from "@/service/index.js";
|
||||
} from "@/api/validation/userValidation.js";
|
||||
import { IUserService } from "@/domain/users/user.service.js";
|
||||
|
||||
const SERVICE_NAME = "authController";
|
||||
|
||||
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user