Compare commits

..
Author SHA1 Message Date
gorkem-bwl e760db1192 fix(monitors): correct proxy transport and error attribution
Second review round found two blocking defects, both verified against a stub
proxy rather than inferred.

HttpsProxyAgent was used for every target, and it always issues CONNECT:

    PROXY RECEIVED: "CONNECT example.com:80 HTTP/1.1"

Squid's stock ACL permits CONNECT only to 443, so every http:// monitor would
have broken the moment HTTP_PROXY was set — in exactly the hardened networks
this feature targets. Plain-http targets now use http-proxy-agent, which
sends an absolute-URI request. The agent cache keys on target scheme.

A refused CONNECT is replayed onto the socket, so the proxy's own status was
parsed as the target's: a proxy ACL denial reported "the site returned 403",
and a monitor whose customUpCodes included 403 or 407 was marked UP while the
target was never reached. Proxy-tunnel rejections are now detected via the
agent's proxyConnect event and reported as a network error naming the proxy.
The listener is per request and removed in a finally block, so a failure
cannot be attributed to a concurrent check.

Also from review:

- The fallback for an invalid PROXY_MONITOR_TYPES no longer fires when every
  entry was a deliberate but unproxyable type ("ping,port"). Falling back
  there would have proxied http and pagespeed, which the operator never
  nominated. Typos still fall back; unproxyable lists stay empty and warn.
- Dropped the now-redundant strict/insecure agent split: TLS is applied per
  request, and Node already separates socket pools by TLS options, so the
  split only doubled tunnels to the proxy.
- Renamed to ProxyPolicy.ts to match the PascalCase peers in the directory.
- The warning no longer echoes the raw config value.

Adds 9 tests covering scheme selection, tunnel rejection, and the narrowed
fallback. Verified by reverting: the tunnel-failure test fails without the fix.
2026-08-20 10:58:55 -04:00
gorkem-bwl 3b03850a3b fix(monitors): keep ignoreTlsErrors effective for proxied checks
Review found that the proxy path silently dropped ignoreTlsErrors.
rejectUnauthorized was passed to the HttpsProxyAgent constructor, where it
governs the socket to the proxy rather than the tunnelled handshake with the
target. Verified directly: the value lands in connectOpts, while the target
handshake reads got's per-request https options, which were unset and so
defaulted to strict.

The consequence was worse than a no-op. A monitor with ignoreTlsErrors
against a self-signed endpoint works today; configuring a proxy would have
made it fail verification, flip to down and fire a false incident with
notifications. Stating the option per request fixes it, and the agent cache
keeps its TLS split so a relaxed agent is never shared with a strict monitor.

Also from review:

- A fully-invalid PROXY_MONITOR_TYPES disabled proxying entirely, which in a
  restricted-egress deployment would take every HTTP monitor down at once —
  the exact failure the parser was meant to prevent. It now falls back to the
  defaults and warns loudly. "none" remains the way to disable deliberately.
- The "none" sentinel only matched when it was the sole entry, so "none,http"
  half-enabled proxying. It is now honoured wherever it appears.
- "unknown" is a real MonitorType but never a deliberate choice, so it is
  reported as a typo rather than as an unproxyable type.
- Dropped the unused warning field from ProxyResolution.
- Documented that lowercase proxy variables are honoured and take precedence,
  which matters on base images that already set them.

Adds 10 tests, including the request-level TLS assertions the previous suite
lacked. Verified by reverting the fix: both TLS tests fail without it.
2026-08-20 10:46:25 -04:00
gorkem-bwl 705c19f7c8 feat(monitors): route checks through an outbound HTTP proxy
Deployments with restricted egress could not run checks at all: got does not
read HTTP_PROXY/HTTPS_PROXY (unlike curl or axios), and NODE_USE_ENV_PROXY
only affects Node's built-in fetch, which got does not use. Even a correctly
built proxy agent was discarded, because HttpProvider overwrote
options.agent on every request.

Proxy selection is resolved per check and applied where the agent is chosen.
NO_PROXY handling is delegated to proxy-from-env rather than reimplemented,
so wildcards, host lists and port-qualified entries behave exactly as
operators expect from curl.

Infrastructure monitors are excluded by default. Hardware and pagespeed
checks both delegate to HttpProvider, so a naive global proxy would also
capture Capture agents — which normally sit on the internal network, where
proxying them fails or hairpins. PROXY_MONITOR_TYPES is the escape hatch for
deployments whose agents genuinely are behind the proxy, and accepts "none"
to disable proxying without unsetting the environment variables. Values that
are not real monitor types, or that open raw sockets and cannot be proxied
(ping, port, docker, grpc, websocket, dns, game), are dropped and logged so
a typo cannot silently disable proxying.

Agents are cached by proxy URL and TLS mode. Keying on TLS matters: sharing
one agent between a strict monitor and an ignoreTlsErrors monitor would leak
the relaxed setting across monitors. Caching also preserves pooling, so 50
monitors share tunnels instead of dialling the proxy per check.

Both new dependencies were already present transitively (76K total).

Notification providers still call out directly and are unaffected; that is a
known follow-up for the same class of deployment.
2026-08-20 10:39:36 -04:00
119 changed files with 3590 additions and 6302 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+4 -9
View File
@@ -32,13 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br>
@@ -92,10 +91,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
The image is configured entirely through environment variables on the server container:
@@ -212,9 +207,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+6 -2
View File
@@ -2,5 +2,9 @@ apiVersion: v2
name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.3.0
appVersion: "latest" # the all-in-one image publishes :latest on release tags; pin a version here to freeze
version: 0.2.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+3 -41
View File
@@ -19,8 +19,8 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml
Edit `values.yaml` to update:
- `app.ingress.host` with your domain name
- `app.protocol` (usually http or https)
- `client.ingress.host` and `api.ingress.host` with your domain names
- `api.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Add: `persistence.mongo.storageClass` (leave empty for default)
@@ -95,7 +95,7 @@ chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate # repo only
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
```
@@ -244,41 +244,3 @@ kubectl get secrets | grep checkmate-tls
```
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
## Upgrading to chart 0.3.0 (all-in-one image)
Checkmate now ships as a single image that serves both the client and the API, so
the chart deploys one workload and one ingress instead of two.
**What changed**
| 0.2.x | 0.3.0 |
|---|---|
| `client.*` + `api.*` value blocks | one `app.*` block |
| `checkmate-client` + `checkmate-backend` images | `ghcr.io/bluewave-labs/checkmate` |
| `ghcr.io/bluewave-labs/checkmate-mongo` | upstream `mongo:8.0` |
| Deployment `checkmate-api` + `checkmate-client` | Deployment `checkmate` |
| Ingress `checkmate-api-ingress` (`/api/v1`) + client ingress (`/`) | Ingress `checkmate-ingress` (`/`) |
| `UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST` | not set — the client is same-origin |
`appVersion` is no longer frozen at v3.8.1; the chart tracks the published image again.
**Upgrading an existing install**
Existing `client.*`, `api.*` and `server.*` overrides are still read and merged over
the `app.*` defaults, so most values files upgrade without edits. Two cases fail
deliberately rather than deploying something wrong:
- `client.image` set — that image is no longer published, so the chart stops instead
of leaving you with an ImagePullBackOff. Remove the `client:` block.
- `client.ingress.host` and `api.ingress.host` set to *different* hosts — one origin
now serves both, so the chart cannot honour a split. Set a single `app.ingress.host`.
Migrate overrides to `app.*` when convenient; the legacy blocks are transitional.
**One ingress, one host**
If you previously served the client and API on separate hostnames, external clients
now use the single `app.ingress.host`. Update any bookmarks, uptime checks, or DNS
records that pointed at the old API hostname.
+11 -34
View File
@@ -1,41 +1,18 @@
{{- /*
Back-compat shim for the all-in-one image migration.
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
Chart 0.2.x deployed a separate client and API, configured through `client.*` and
`api.*` (and, before that, `server.*`). Checkmate now ships one image that serves
both, so those collapse into a single `app.*` block.
Existing installs keep working: any legacy block is merged over the `app.*`
defaults, in the order client -> api -> server, so a value set in an older block
still wins. Only `image`/`tag` are deliberately NOT inherited from `client.*` —
that repo (checkmate-client) is no longer published, so honouring it would pin an
install to an image that cannot be pulled.
New installs have no legacy keys and this is a no-op deepCopy of `.Values.app`.
Consume it as:
{{- $app := include "checkmate.app" . | fromYaml -}}
*/}}
{{- define "checkmate.app" -}}
{{- $app := deepCopy .Values.app -}}
{{- with .Values.client -}}
{{- $legacy := omit (deepCopy .) "image" "tag" "port" -}}
{{- $app = mergeOverwrite $app $legacy -}}
{{- end -}}
{{- with .Values.api -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- with .Values.server -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- $app | toYaml -}}
{{- end -}}
{{- /*
Deprecated alias kept so any out-of-tree template referencing `checkmate.api`
still resolves. Prefer `checkmate.app`.
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
{{- $api := include "checkmate.api" . | fromYaml -}}
then reference `$api.*` instead of `.Values.api.*`.
*/}}
{{- define "checkmate.api" -}}
{{- include "checkmate.app" . -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}}
{{- /*
@@ -0,0 +1,65 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -0,0 +1,39 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
annotations:
{{- range $key, $value := $api.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{/*#annotations:
#nginx.ingress.kubernetes.io/rewrite-target: /
#nginx.ingress.kubernetes.io/enable-cors: "true"
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
tls:
- hosts:
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $api.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-api
port:
number: {{ $api.port }}
{{- end }}
@@ -0,0 +1,10 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,71 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate
template:
metadata:
labels:
app: checkmate
spec:
{{- with $app.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $app.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: checkmate
image: {{ include "checkmate.image" (dict "image" $app.image "tag" $app.tag "root" $) }}
imagePullPolicy: {{ $app.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $app.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the app is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $app.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $app.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $app.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $app.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $app.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $app.probes.readiness.failureThreshold }}
{{- with $app.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
# The client is served from this same origin, so CLIENT_HOST defaults to
# the app's own ingress URL. `env` takes precedence over `envFrom`, so an
# explicit secrets.CLIENT_HOST is honoured by omitting this entry rather
# than emitting a value that would silently win over it.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
- name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
{{- end }}
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the app only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,35 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- if $app.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-ingress
{{- if $app.ingress.annotations }}
annotations:
{{- range $key, $value := $app.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ $app.ingress.className }}
{{- if $app.ingress.tls.enabled }}
tls:
- hosts:
- {{ $app.ingress.host }}
secretName: {{ default (printf "%s-tls" .Release.Name) $app.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $app.ingress.host }}
http:
paths:
# The application serves both the client and /api/v1 from one origin, so
# a single root rule replaces the separate client and API ingresses. No
# CORS annotations are needed now that the browser is same-origin.
- path: /
pathType: Prefix
backend:
service:
name: checkmate
port:
number: {{ $app.port }}
{{- end }}
@@ -1,10 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate
spec:
selector:
app: checkmate
ports:
- port: {{ $app.port }}
@@ -0,0 +1,46 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-client
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-client
template:
metadata:
labels:
app: checkmate-client
spec:
{{- with .Values.client.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.client.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: client
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
ports:
- containerPort: {{ .Values.client.port }}
env:
- name: UPTIME_APP_API_BASE_URL
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
{{- with .Values.client.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
volumes:
- name: config-volume
configMap:
name: checkmate-client-nginx-cm
@@ -0,0 +1,31 @@
{{- if .Values.client.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-client-ingress
{{- if .Values.client.ingress.annotations }}
annotations:
{{- range $key, $value := .Values.client.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ .Values.client.ingress.className }}
{{- if .Values.client.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.client.ingress.host }}
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.client.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkmate-client
port:
number: {{ .Values.client.port }}
{{- end }}
@@ -0,0 +1,42 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: checkmate-client-nginx-cm
data:
default.conf: |
server {
listen 80;
listen [::]:80;
server_name checkmate-demo.bluewavelabs.ca;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# location /api/ {
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
location /api-docs/ {
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-client
spec:
selector:
app: checkmate-client
ports:
- port: {{ .Values.client.port }}
+36 -32
View File
@@ -1,45 +1,49 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- if eq $app.ingress.host "change_me" }}
{{- fail "app.ingress.host must be overridden and not set to 'change_me'" }}
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $protocol := $app.protocol }}
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }}
{{- fail "app.protocol must be either 'http' or 'https'" }}
{{- if eq $api.ingress.host "change_me" }}
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $apiProtocol := $api.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
{{- fail "client.protocol must be either 'http' or 'https'" }}
{{- end }}
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
{{- if and $app.ingress.tls.enabled (ne $protocol "https") }}
{{- fail "app.ingress.tls.enabled is true but app.protocol is not 'https'. Set app.protocol: https to avoid mixed content." }}
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
{{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $ann := $app.ingress.annotations | default dict }}
{{- $hasIssuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") }}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }}
{{- if and $app.ingress.tls.enabled (not $hasIssuer) }}
{{- fail "app.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/*
Upgrade guard. The client and API are one service now, so a values file that still
points the two at different hosts cannot be honoured — fail loudly rather than
silently dropping one of them.
*/}}
{{- if and .Values.client .Values.api }}
{{- $clientHost := (.Values.client.ingress | default dict).host }}
{{- $apiHost := (.Values.api.ingress | default dict).host }}
{{- if and $clientHost $apiHost (ne $clientHost $apiHost) }}
{{- fail (printf "client.ingress.host (%s) and api.ingress.host (%s) differ, but Checkmate now serves the client and the API from one origin. Set a single app.ingress.host instead." $clientHost $apiHost) }}
{{- end }}
{{- end }}
{{/*
The client image is no longer published. Point this out rather than letting the
install fail later on an ImagePullBackOff nobody can action.
*/}}
{{- if and .Values.client .Values.client.image }}
{{- fail "client.image is set, but the separate client image is no longer published. Remove the client block; the application image in app.image now serves the client." }}
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
@@ -47,4 +51,4 @@
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-tls in templates */}}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -1,4 +1,4 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
@@ -27,8 +27,8 @@ spec:
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $app.image) "tag" (.Values.worker.tag | default $app.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $app.imagePullPolicy }}
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}
@@ -36,13 +36,9 @@ spec:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# validateEnv requires a URL-shaped CLIENT_HOST, so mirror the app's default
# here. Omitted when secrets.CLIENT_HOST is set, since `env` would otherwise
# take precedence over the envFrom value and silently discard the override.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
{{- end }}
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
+35 -24
View File
@@ -1,17 +1,31 @@
# Checkmate is published as a single all-in-one application image: the API server
# also serves the React client, so one deployment and one ingress cover both.
# The `client:` and `api:` blocks from chart 0.2.x are still read for back-compat
# (see templates/_helpers.tpl) — `app.*` is the block to set on new installs.
app:
image: ghcr.io/bluewave-labs/checkmate
client:
image: ghcr.io/bluewave-labs/checkmate-client
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 80
protocol: http
ingress:
enabled: true
host: change_me
className: nginx
annotations: {}
# Example annotations for cert-manager:
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
# Note: when enabling TLS, also set client.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
api:
image: ghcr.io/bluewave-labs/checkmate-backend
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 52345
protocol: http
resources: {}
affinity: {}
tolerations: {}
# Liveness/readiness probes hit GET /api/v1/health on the app port (proves the HTTP server is up).
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
@@ -33,27 +47,25 @@ app:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-tls # Optional; defaults to <release>-tls if omitted
# Note: when enabling TLS, also set app.protocol: https and add
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
# Note: when enabling TLS, also set api.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
# Upstream MongoDB. The former ghcr.io/bluewave-labs/checkmate-mongo image is no
# longer published; this matches the mongo version in docker/docker-compose.yaml.
mongodb:
image: mongo
tag: "8.0"
imagePullPolicy: IfNotPresent
image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017
worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the app stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# app is all-in-one and processes jobs itself.
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# API is all-in-one and processes jobs itself.
enabled: false
image: "" # defaults to app.image when empty, same as the application image
tag: "" # defaults to app.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to app.imagePullPolicy when empty
image: "" # defaults to api.image when empty, same as API image
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
@@ -85,8 +97,7 @@ worker:
secrets:
JWT_SECRET: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
# CLIENT_HOST is derived from app.protocol + app.ingress.host by the deployment;
# set it here only to override that (e.g. when fronting the app with another proxy).
CLIENT_HOST: change_me
NODE_ENV: production
LOG_LEVEL: info
TOKEN_TTL: 99d
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.10.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
+1 -2
View File
@@ -1,10 +1,9 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.10.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
+4 -15
View File
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
/>
);
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select}
{helper}
</Stack>
);
}
@@ -8,7 +8,6 @@ import {
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
@@ -18,12 +17,10 @@ export interface FormRadioOption {
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
@@ -33,7 +30,6 @@ export const FormRadioGroup = <T extends FieldValues>({
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
@@ -44,7 +44,6 @@ export const FormSelectField = <T extends FieldValues>({
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+1 -6
View File
@@ -18,8 +18,8 @@ import {
Lock,
Users,
Tag,
Waypoints,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
@@ -47,11 +47,6 @@ export const getMenu = (t: Function) => {
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
+1 -11
View File
@@ -2,13 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed";
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
interface TableState {
rowsPerPage: number;
@@ -20,7 +14,6 @@ interface SidebarState {
interface UIState {
monitors: TableState;
pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
@@ -44,9 +37,6 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
pagespeed: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
-5
View File
@@ -4,7 +4,6 @@ import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
@@ -47,8 +46,6 @@ export const getMonitorDefaults = (
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
expectedValue: data?.expectedValue || "",
@@ -144,8 +141,6 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod,
expectedValue: "",
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+5 -76
View File
@@ -41,12 +41,9 @@ import {
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
ProxyModes,
type ProxyMode,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag";
import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
@@ -59,7 +56,6 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
@@ -71,7 +67,6 @@ interface GeneralSettingsConfig {
urlPlaceholder: string;
namePlaceholder: string;
showUrl: boolean;
showProxy: boolean;
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
@@ -92,7 +87,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: true,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -107,7 +101,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -122,7 +115,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -137,7 +129,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: false,
showSecret: false,
@@ -152,7 +143,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: true,
showSecret: false,
@@ -167,7 +157,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: true,
showGameSelect: false,
showSecret: false,
@@ -182,7 +171,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -197,7 +185,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: true,
@@ -212,7 +199,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -227,7 +213,6 @@ const getGeneralSettingsConfig = (
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
showPort: false,
showGameSelect: false,
showSecret: false,
@@ -273,8 +258,6 @@ const CreateMonitorPage = () => {
const { data: notifications } = useGet<Notification[]>("/notifications/team");
const { data: games } = useGet<GamesMap>("/monitors/games");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { data: proxies } = useGet<ProxyResponse[]>("/proxies/team");
const { data: appSettings } = useGet<AppSettingsResponse>("/settings");
const { schema, defaults } = useMonitorForm({
data: existingMonitor ?? null,
@@ -298,7 +281,6 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -484,38 +466,6 @@ const CreateMonitorPage = () => {
})),
[t]
);
const globalProxyName = appSettings?.globalProxy?.name;
const proxyModeOptions = useMemo(
() =>
ProxyModes.map((mode) => {
const key =
mode === "inherit"
? globalProxyName
? "inheritGlobal"
: "inheritDirect"
: mode;
return {
value: mode,
label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, {
name: globalProxyName,
}),
description: t(
`pages.createMonitor.form.general.option.proxyMode.description.${key}`
),
};
}),
[t, globalProxyName]
);
const proxyOptions = useMemo(
() =>
(proxies ?? []).map((proxy) => ({
value: proxy.id,
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
})),
[proxies]
);
return (
<FormProvider {...form}>
@@ -568,11 +518,6 @@ const CreateMonitorPage = () => {
}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="name"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
/>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<FormTextField
@@ -582,27 +527,6 @@ const CreateMonitorPage = () => {
disabled={isEditMode}
/>
)}
{/* Proxy fields - only shown for HTTP */}
{generalSettingsConfig.showProxy && (
<>
<FormRadioGroup
name="proxyMode"
options={proxyModeOptions}
fieldLabel={t(
"pages.createMonitor.form.general.option.proxyMode.label"
)}
/>
{watchedProxyMode === "custom" && (
<FormSelectField
name="proxyId"
fieldLabel={t(
"pages.createMonitor.form.general.option.proxy.label"
)}
options={proxyOptions}
/>
)}
</>
)}
{/* Strategy field - only for pagespeed type */}
{generalSettingsConfig.showStrategy && (
@@ -679,6 +603,11 @@ const CreateMonitorPage = () => {
options={dnsRecordTypeOptions}
/>
)}
<FormTextField
name="name"
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
placeholder={generalSettingsConfig.namePlaceholder}
/>
</Stack>
}
/>
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const c = useMonitorListController({
types: ["hardware"],
checksLimit: 1,
refreshInterval: 5000,
rowsPerPageTable: "infrastructure",
rowsPerPageDefault: 5,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure"
actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<InfraMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
} = useGet<AppSettingsResponse>("/settings");
} = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({
types: ["pagespeed"],
checksLimit: 25,
refreshInterval: 30000,
rowsPerPageTable: "pagespeed",
rowsPerPageDefault: 10,
initialSortField: "name",
});
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const [sortField, setSortField] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed"
actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
>
<PageSpeedMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
<HeaderCreate
path="/pagespeed/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -1,103 +0,0 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
-213
View File
@@ -1,213 +0,0 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Button, FieldLabel } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { ProxyFormData } from "@/Validation/proxy";
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
import { useTranslation } from "react-i18next";
import { useProxiesForm } from "@/Hooks/useProxiesForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { useMemo } from "react";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const ProxiesCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { proxyId } = useParams<{ proxyId: string }>();
const isEditMode = Boolean(proxyId);
const { data: existingProxy } = useGet<ProxyResponse>(
isEditMode ? `/proxies/${proxyId}` : null
);
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
const { schema, defaults } = useProxiesForm({ data: existingProxy });
const form = useForm<ProxyFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { reset, handleSubmit } = form;
const isPasswordSet = existingProxy?.hasPassword ?? false;
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
const handleResetPassword = () => {
form.setValue("password", "");
setPasswordHasBeenReset(true);
};
const isUsernameSet = Boolean(existingProxy?.username);
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
const handleResetUsername = () => {
form.setValue("username", "");
setUsernameHasBeenReset(true);
};
const protocolOptions = useMemo(
() =>
ProxyProtocols.map((protocol) => ({
value: protocol,
label: protocol,
})),
[]
);
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: ProxyFormData) => {
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
{
...data,
};
// Don't send password/username if one is set and the user hasn't clicked reset
if (isPasswordSet && !passwordHasBeenReset) {
delete payload.password;
} else if (passwordHasBeenReset && !payload.password) {
payload.clearPassword = true;
}
if (isUsernameSet && !usernameHasBeenReset) {
delete payload.username;
} else if (usernameHasBeenReset && !payload.username) {
payload.clearUsername = true;
}
const result = isEditMode
? await patch(`/proxies/${proxyId}`, payload)
: await post("/proxies", payload);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
navigate("/proxies");
}
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.proxies.form.name.title")}
subtitle={t("pages.proxies.form.name.description")}
rightContent={
<FormTextField
name="name"
fieldLabel={t("pages.proxies.form.name.optionName")}
placeholder={t("pages.proxies.form.name.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.proxies.form.server.title")}
subtitle={t("pages.proxies.form.server.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSelectField
name="protocol"
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
options={protocolOptions}
/>
<FormTextField
name="host"
fieldLabel={t("pages.proxies.form.server.option.host.label")}
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
/>
<FormNumberField
name="port"
fieldLabel={t("pages.proxies.form.server.option.port.label")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.proxies.form.auth.title")}
subtitle={t("pages.proxies.form.auth.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
{(isUsernameSet === false || usernameHasBeenReset === true) && (
<FormTextField
name="username"
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
/>
)}
{isUsernameSet === true && usernameHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.username.labelSet")}
</FieldLabel>
<Button
onClick={handleResetUsername}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
{(isPasswordSet === false || passwordHasBeenReset === true) && (
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
/>
)}
{isPasswordSet === true && passwordHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.password.labelSet")}
</FieldLabel>
<Button
onClick={handleResetPassword}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
export default ProxiesCreatePage;
-72
View File
@@ -1,72 +0,0 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { ProxyResponse } from "@/Types/Proxy";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const ProxiesPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
const isDialogOpen = Boolean(selectedProxy);
const {
data: proxies,
isLoading,
isValidating,
error,
refetch,
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedProxy) return;
await deleteFn(`/proxies/${selectedProxy.id}`);
setSelectedProxy(null);
refetch();
};
const handleCancel = () => {
setSelectedProxy(null);
};
return (
<BasePageWithStates
headerKey="proxies"
page={t("pages.proxies.fallback.title")}
description={t("pages.proxies.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={proxies?.length ?? 0}
actionButtonText={t("pages.proxies.fallback.actionButton")}
actionLink="/proxies/create"
>
<HeaderCreate
path="/proxies/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<ProxiesTable
proxies={proxies ?? []}
setSelectedProxy={setSelectedProxy}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default ProxiesPage;
+9 -66
View File
@@ -10,7 +10,7 @@ import {
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { FormProvider, useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
@@ -43,9 +43,12 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import type { ProxyResponse } from "@/Types/Proxy";
import type { AppSettingsResponse } from "@/Types/Settings";
interface SettingsResponse {
settings: any;
pagespeedKeySet: boolean;
emailPasswordSet: boolean;
}
export const SettingsPage = () => {
const theme = useTheme();
@@ -61,20 +64,9 @@ export const SettingsPage = () => {
const { post: importMonitors, loading: isImportingMonitors } = usePost();
// Fetch settings data from API
const { data: fetchedSettings } = useGet<AppSettingsResponse>("/settings");
// Get proxies
const { data: proxies } = useGet<ProxyResponse[]>("/proxies");
const proxyOptions = useMemo(
() =>
(proxies ?? []).map((proxy) => ({
value: proxy.id,
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
})),
[proxies]
);
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
// Form submission
const { patch, loading: isSaving } = usePatch<SettingsFormData, AppSettingsResponse>();
const { patch, loading: isSaving } = usePatch<SettingsFormData, SettingsResponse>();
// Local state for API key reset
const [isApiKeySet, setIsApiKeySet] = useState(
@@ -123,8 +115,6 @@ export const SettingsPage = () => {
mode: "onChange",
});
const { watch } = form;
// Reset form when defaults change
useEffect(() => {
form.reset(defaults);
@@ -302,21 +292,6 @@ export const SettingsPage = () => {
const languages = Object.keys(i18n.options.resources || {});
const globalProxyEnabled = watch("globalProxyEnabled");
// Enabling the proxy with nothing selected preselects the first proxy
useEffect(() => {
if (
globalProxyEnabled &&
!form.getValues("globalProxyId") &&
proxyOptions.length > 0
) {
form.setValue("globalProxyId", proxyOptions[0].value, {
shouldValidate: true,
});
}
}, [globalProxyEnabled, proxyOptions, form]);
return (
<FormProvider {...form}>
<BasePage
@@ -390,38 +365,6 @@ export const SettingsPage = () => {
</Stack>
}
/>
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.globalProxy.title")}
subtitle={t("pages.settings.form.globalProxy.description")}
rightContent={
proxyOptions.length > 0 ? (
<Stack>
<FormSwitchField
name="globalProxyEnabled"
labelPlacement="start"
label={t("pages.settings.form.globalProxy.option.enabled.label")}
/>
{globalProxyEnabled && (
<FormSelectField
fieldLabel={t(
"pages.settings.form.globalProxy.option.proxy.label"
)}
name="globalProxyId"
options={proxyOptions}
/>
)}
</Stack>
) : (
<TextLink
text={t("pages.settings.form.globalProxy.option.empty.text")}
linkText={t("pages.settings.form.globalProxy.option.empty.link")}
href="/proxies"
/>
)
}
/>
)}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.pagespeed.title")}
+15 -36
View File
@@ -1,9 +1,4 @@
import {
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
@@ -21,12 +16,12 @@ import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statu
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { SelectableMonitorTypes } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { timezoneOptions } from "@/Utils/timezoneOptions";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
@@ -58,9 +53,7 @@ const LogoUploadField = () => {
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
}}
/>
);
@@ -93,8 +86,6 @@ const CreateStatusPage = () => {
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
const { post, loading: isSubmittingPost } = usePost();
const { put, loading: isSubmittingPut } = usePut();
@@ -205,8 +196,18 @@ const CreateStatusPage = () => {
if (data.logo === null) {
// Signal to remove the logo
fd.append("removeLogo", "true");
} else if (data.logo?.file) {
fd.append("logo", data.logo.file);
} else if (data.logo?.data && data.logo.data !== "") {
if (data.logo.data.startsWith("blob:")) {
try {
const imageResult = await axios.get(data.logo.data, {
responseType: "blob",
});
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
}
let result;
@@ -307,28 +308,6 @@ const CreateStatusPage = () => {
"pages.statusPages.form.monitors.option.monitors.placeholder"
)}
options={monitors}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
{monitor.tags.map((tagId) => {
const tag = tags.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
renderRow={(monitor) => (
<Typography flexGrow={1}>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
@@ -113,7 +113,6 @@ const StatusPageView = () => {
apiUrl,
{},
{
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
}
);
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -34,9 +34,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -327,12 +327,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "3px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -347,11 +344,8 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
@@ -38,9 +38,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -272,11 +272,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -288,11 +285,8 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
@@ -1,6 +1,6 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -37,9 +37,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -83,14 +83,13 @@ export const modernStyles = (
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
empty: tokens.border,
};
@@ -104,7 +103,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
};
return {
@@ -355,19 +354,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind, severity = 1) => {
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
return {
borderRadius: "3px",
background:
kind === "degraded"
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
@@ -377,11 +370,8 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
@@ -37,9 +37,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -296,12 +296,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "2px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +312,8 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
@@ -59,9 +59,9 @@ export interface BaseStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -18,7 +18,6 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
export interface ChartCell {
key: string;
severity: number;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
@@ -29,22 +28,9 @@ export interface ChartCell {
const DAY_DOWN_THRESHOLD = 0.5;
// Color interpolation
// 0 = perfect
// 1 = at or below the down threshold
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
export const severityFromUpRatio = (upRatio: number): number => {
const failed = 1 - upRatio;
if (failed <= 0) return 0;
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
const clamped = Math.min(1, Math.max(0, scaled));
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
};
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
if (bucket.totalChecks === 0) return "empty";
const upRatio = bucket.upChecks / bucket.totalChecks;
if (upRatio >= 1) return "up";
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
return "degraded";
@@ -69,7 +55,6 @@ const emptyCell = (key: string): ChartCell => ({
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -91,7 +76,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
responseTime: check.responseTime ?? 0,
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
@@ -109,17 +93,9 @@ export const dailyBucketsToCells = (
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
const barKind = classifyDay(bucket);
return {
key: `day-${date}`,
severity:
barKind === "degraded"
? severityFromUpRatio(upRatio)
: barKind === "down"
? 1
: 0,
barKind,
heatKind:
barKind === "up"
@@ -10,7 +10,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
}
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
placement="top"
>
<Box
sx={cellSx(cell.heatKind, cell.severity)}
sx={cellSx(cell.heatKind)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -13,7 +13,7 @@ import type {
interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
sx={barSx(cell.barKind, cell.heightPct)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
editorial,
minimal,
};
// Interpolate between a theme's up and degraded colors by severity (0..1).
export const mixSeverityColor = (
up: string,
degraded: string,
severity: number
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
+280 -29
View File
@@ -1,43 +1,294 @@
import { MonitorListPage } from "@/Components/monitors";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import Typography from "@mui/material/Typography";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { SPACING } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const c = useMonitorListController({
types: "selectable",
checksLimit: 25,
refreshInterval: 5000,
rowsPerPageTable: "monitors",
rowsPerPageDefault: 10,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
);
// Local state
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const {
monitors: monitorsWithChecks,
summary,
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
// Handlers
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="uptime"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="uptime"
actionLink="/uptime/create"
controller={c}
bulkActions
>
<MonitorTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/uptime/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<MonitorTable
monitors={monitorsWithChecks || []}
tags={tags || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setRowsPerPage={(rowsPerPage: number) => {
dispatch(
setRowsPerPage({
value: rowsPerPage,
table: "monitors",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+4 -17
View File
@@ -39,14 +39,11 @@ import CreateNotifications from "@/Pages/Notifications/create";
import Tags from "@/Pages/Tags";
import CreateTags from "@/Pages/Tags/create";
// Proxies
import Proxies from "@/Pages/Proxies";
import CreateProxies from "@/Pages/Proxies/create";
// Settings
import Account from "@/Pages/Account";
import EditUser from "@/Pages/Account/EditUser";
import Settings from "@/Pages/Settings";
import Maintenance from "@/Pages/Maintenance";
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
@@ -55,8 +52,10 @@ import Logs from "@/Pages/Logs";
// Routing
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
@@ -189,24 +188,12 @@ const Routes = () => {
path="tags/create"
element={<CreateTags />}
/>
<Route
path="tags/configure/:tagId"
element={<CreateTags />}
/>
<Route
path="proxies"
element={<Proxies />}
/>
<Route
path="proxies/create"
element={<CreateProxies />}
/>
<Route
path="proxies/configure/:proxyId"
element={<CreateProxies />}
/>
<Route
path="maintenance"
element={<Maintenance />}
-6
View File
@@ -109,10 +109,6 @@ export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const DefaultProxyMode: ProxyMode = "inherit";
export interface Monitor {
id: string;
userId: string;
@@ -125,8 +121,6 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
-17
View File
@@ -1,17 +0,0 @@
export const ProxyProtocols = ["http", "https"] as const;
export type ProxyProtocol = (typeof ProxyProtocols)[number];
export interface Proxy {
id: string;
teamId: string;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string;
password?: string;
createdAt: string;
updatedAt: string;
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
-8
View File
@@ -24,8 +24,6 @@ export interface Settings {
showURL: boolean;
singleton: boolean;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -33,11 +31,5 @@ export interface Settings {
export interface AppSettingsResponse {
pagespeedKeySet: boolean;
emailPasswordSet: boolean;
globalProxy: {
id: string;
name: string;
host: string;
port: number;
} | null;
settings: Settings;
}
+11 -17
View File
@@ -1,31 +1,25 @@
/**
* Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance.
*
* Two decimals rather than one: at a single decimal every value above 99.95%
* renders as "100.0%", so a period containing real downtime is reported as
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
* different SLA commitments — into the same figure.
*/
const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
/**
* Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%")
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentage(0.75) // "75.00%"
* formatPercentage(1) // "100.00%"
* formatPercentage(0.5432) // "54.32%"
* formatPercentage(0.9999) // "99.99%"
* formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.3%"
*/
export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.00%";
return "0.0%";
}
return percentageFormatter.format(value);
};
@@ -33,11 +27,11 @@ export const formatPercentage = (value: number): string => {
/**
* Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%")
* @returns Formatted percentage string (e.g., "75.0%")
* @example
* formatPercentageFromWhole(75) // "75.00%"
* formatPercentageFromWhole(100) // "100.00%"
* formatPercentageFromWhole(54.32) // "54.32%"
* formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.3%"
*/
export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100);
+2 -21
View File
@@ -5,7 +5,6 @@ import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
ProxyModes,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -68,13 +67,6 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code),
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
proxyMode: z.enum(ProxyModes),
// The form uses "" for "no proxy selected". drop it from the payload so the
// server never receives an empty ObjectId
proxyId: z
.string()
.transform((value) => (value === "" ? undefined : value))
.optional(),
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
@@ -203,7 +195,7 @@ const dnsSchema = baseSchema.extend({
});
// Discriminated union of all monitor types
const monitorSchemaUnion = z.discriminatedUnion("type", [
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
pingSchema,
portSchema,
@@ -216,21 +208,10 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection (monitorValidation.ts)
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
const optionForType = (type: MonitorType) =>
monitorSchemaUnion.options.find((o) => o.shape.type.value === type);
monitorSchema.options.find((o) => o.shape.type.value === type);
// The step each field of a type is validated on, from the per-field
// `monitorStepRegistry` metadata (unannotated fields default to step 0).
-17
View File
@@ -1,17 +0,0 @@
import { z } from "zod";
import { ProxyProtocols } from "@/Types/Proxy";
export const proxySchema = z.object({
name: z.string().min(1, "Proxy name is required"),
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
host: z.string().min(1, "Proxy host is required"),
port: z
.number()
.int()
.min(1, "Proxy port is required")
.max(65535, "Proxy port must be between 1 and 65535"),
username: z.string().optional(),
password: z.string().optional(),
});
export type ProxyFormData = z.infer<typeof proxySchema>;
+55 -67
View File
@@ -1,73 +1,61 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z
.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
globalProxyEnabled: z.boolean(),
globalProxyId: z.string().nullable().optional(),
})
.superRefine((body, ctx) => {
if (body.globalProxyEnabled === true && !body.globalProxyId) {
ctx.addIssue({
code: "custom",
path: ["globalProxyId"],
message: "A proxy must be selected to enable the global proxy",
});
}
});
export const settingsSchema = z.object({
systemEmailIgnoreTLS: z.boolean(),
systemEmailRequireTLS: z.boolean(),
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailDisplayName: z
.string()
.max(100, "Display name must be 100 characters or fewer")
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
});
export type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
-1
View File
@@ -79,7 +79,6 @@ export const statusPageSchema = z.object({
.object({
data: z.string(),
contentType: z.string(),
file: z.instanceof(File).optional(),
})
.nullable()
.optional()
+1 -93
View File
@@ -205,7 +205,6 @@
"infrastructure": "Infrastructure",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
"checks": "Checks",
"incidents": "Incidents",
"statusPages": "Status pages",
@@ -245,63 +244,6 @@
}
},
"pages": {
"proxies": {
"header": {
"title": "Proxies",
"description": "Manage your proxy servers for routing HTTP monitoring traffic."
},
"fallback": {
"title": "No proxies yet",
"description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.",
"actionButton": "Create a proxy"
},
"form": {
"name": {
"title": "Proxy name",
"description": "A friendly name to identify this proxy",
"optionName": "Proxy name",
"placeholder": "My proxy"
},
"server": {
"title": "Proxy server",
"description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.",
"option": {
"protocol": {
"label": "Protocol"
},
"host": {
"label": "Hostname / IP Address",
"placeholder": "proxy.example.com"
},
"port": {
"label": "Port"
}
}
},
"auth": {
"title": "Authentication",
"description": "Optional. Only needed if the proxy server requires basic authentication.",
"option": {
"username": {
"label": "Username",
"placeholder": "Proxy username",
"labelSet": "Username is set. Click Reset to change it."
},
"password": {
"label": "Password",
"labelSet": "Password is set. Click Reset to change it."
}
}
}
},
"table": {
"headers": {
"protocol": "Protocol",
"host": "Host",
"port": "Port"
}
}
},
"notFound": {
"title": "Oh no! You dropped your sushi!",
"subtitle": "Either the URL doesn’t exist, or you don’t have access to it."
@@ -639,24 +581,6 @@
"label": "URL",
"placeholder": "https://www.google.com"
},
"proxyMode": {
"label": "Proxy",
"value": {
"inheritDirect": "Default (direct connection)",
"inheritGlobal": "Default ({{name}})",
"none": "Never use a proxy",
"custom": "Custom proxy"
},
"description": {
"inheritDirect": "No global proxy is configured, connect directly. If a global proxy is later configured, this monitor will use it.",
"inheritGlobal": "Use the global proxy setting",
"none": "Always connects directly even if a global proxy is enabled later.",
"custom": "Route this monitor's checks through a specific proxy. Overrides global proxy"
}
},
"proxy": {
"label": "Proxy server"
},
"game": {
"label": "Choose game",
"placeholder": "Select a game"
@@ -700,7 +624,7 @@
},
"title": "General settings",
"description": {
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
@@ -1362,22 +1286,6 @@
}
}
},
"globalProxy": {
"title": "Global proxy",
"description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.",
"option": {
"enabled": {
"label": "Use global proxy"
},
"proxy": {
"label": "Choose a global proxy"
},
"empty": {
"text": "No proxies configured yet.",
"link": "Create one first"
}
}
},
"pagespeed": {
"title": "Google PageSpeed API key",
"description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.",
-3
View File
@@ -2,7 +2,6 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit";
import authReducer from "@/Features/Auth/authSlice";
import uiReducer from "@/Features/UI/uiSlice";
import storage from "redux-persist/lib/storage";
import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import {
persistReducer,
persistStore,
@@ -25,8 +24,6 @@ const persistConfig = {
storage,
whitelist: ["auth", "ui"],
transforms: [authTransform],
// Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value
stateReconciler: autoMergeLevel2,
};
const rootReducer = combineReducers({
+21
View File
@@ -32,3 +32,24 @@ ORIGIN=localhost
# Set to "false" to disable the selectable theme feature on status pages.
# When disabled, all status pages render with the default "refined" theme.
STATUS_PAGE_THEMES_ENABLED=true
# Outbound Proxy
# --------------
# Route monitor checks through an HTTP proxy, for deployments whose egress is
# restricted. Credentials may be embedded: http://user:pass@proxy:3128
# The lowercase forms (http_proxy, https_proxy, no_proxy) are also honoured and
# take precedence over these, which matters on base images that already set them.
# HTTP_PROXY=http://proxy.corp.example:3128
# HTTPS_PROXY=http://proxy.corp.example:3128
#
# Hosts that must be reached directly. Supports wildcards and host lists, with
# the same semantics curl uses.
# NO_PROXY=localhost,127.0.0.1,*.internal,10.0.0.5
#
# Monitor types to route through the proxy, comma-separated. Defaults to
# "http,pagespeed". Infrastructure ("hardware") monitors are excluded by
# default because Capture agents normally sit on the internal network — add it
# here if yours are reachable only through the proxy. Use "none" to disable
# proxying without unsetting the variables above. Types that open raw sockets
# (ping, port, docker, grpc, websocket, dns, game) cannot be proxied.
# PROXY_MONITOR_TYPES=http,pagespeed
-1034
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -9,7 +9,6 @@ import "./routes/log.js";
import "./routes/maintenanceWindow.js";
import "./routes/monitor.js";
import "./routes/notification.js";
import "./routes/proxies.js";
import "./routes/queue.js";
import "./routes/settings.js";
import "./routes/statusPage.js";
@@ -46,7 +45,6 @@ export function getOpenApiSpec(): JsonObject {
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
{ name: "settings", description: "Global application settings (admin/superadmin)." },
{ name: "proxies", description: "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." },
{ name: "invite", description: "Team invitations and accepting them." },
{ name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." },
{ name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." },
-75
View File
@@ -1,75 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, okJson, errorEnvelope, standardErrors } from "../helpers.js";
import {
createProxyBodyValidation,
editProxyBodyValidation,
getProxyByIdParamValidation,
editProxyParamValidation,
deleteProxyParamValidation,
proxyResponseSchema,
} from "@/api/validation/proxyValidation.js";
const tags = ["proxies"];
registry.registerPath({
method: "post",
path: "/proxies",
tags,
summary: "Create a proxy for the caller's team",
security: bearer,
request: { body: { content: json(createProxyBodyValidation) } },
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/proxies",
tags,
summary: "List all proxies on the instance (admin/superadmin)",
security: bearer,
responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/proxies/team",
tags,
summary: "List proxies for the caller's team",
security: bearer,
responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/proxies/{id}",
tags,
summary: "Get a proxy by id",
security: bearer,
request: { params: getProxyByIdParamValidation },
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/proxies/{id}",
tags,
summary: "Edit a proxy",
security: bearer,
request: { params: editProxyParamValidation, body: { content: json(editProxyBodyValidation) } },
responses: { "200": okJson(proxyResponseSchema), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/proxies/{id}",
tags,
summary: "Delete a proxy",
security: bearer,
request: { params: deleteProxyParamValidation },
responses: {
"200": okJson(proxyResponseSchema),
"409": { description: "Proxy is in use by monitors or set as the global proxy", content: json(errorEnvelope) },
...standardErrors,
},
});
+1452 -1680
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -5,7 +5,6 @@
"main": "index.js",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
@@ -50,7 +49,8 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"hpagent": "1.2.0",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -60,6 +60,7 @@
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"ping": "0.4.4",
"proxy-from-env": "^2.1.0",
"re2": "^1.24.1",
"sharp": "0.35.3",
"ssl-checker": "2.0.10",
@@ -1,89 +0,0 @@
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { requireTeamId } from "./controllerUtils.js";
import {
createProxyBodyValidation,
editProxyBodyValidation,
getProxyByIdParamValidation,
editProxyParamValidation,
deleteProxyParamValidation,
} from "@/api/validation/index.js";
export interface IProxiesController {
createProxy: RequestHandler;
getProxyById: RequestHandler;
getAllProxies: RequestHandler;
getProxiesByTeamId: RequestHandler;
editProxy: RequestHandler;
deleteProxy: RequestHandler;
}
class ProxyController implements IProxiesController {
constructor(private proxiesService: IProxiesService) {}
createProxy = catchAsync(async (req: Request, res: Response) => {
const validatedBody = createProxyBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const proxy = await this.proxiesService.createProxy(validatedBody, teamId);
return res.status(200).json({
success: true,
msg: "Proxy created successfully",
data: proxy,
});
});
getProxyById = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = getProxyByIdParamValidation.parse(req.params);
const proxy = await this.proxiesService.getProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy retrieved successfully",
data: proxy,
});
});
getAllProxies = catchAsync(async (req: Request, res: Response) => {
const proxies = await this.proxiesService.getProxies();
return res.status(200).json({
success: true,
msg: "Proxies retrieved successfully",
data: proxies,
});
});
getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const proxies = await this.proxiesService.getProxiesByTeamId(teamId);
return res.status(200).json({
success: true,
msg: "Proxies retrieved successfully",
data: proxies,
});
});
editProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = editProxyParamValidation.parse(req.params);
const validatedBody = editProxyBodyValidation.parse(req.body);
const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody);
return res.status(200).json({
success: true,
msg: "Proxy updated successfully",
data: updatedProxy,
});
});
deleteProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = deleteProxyParamValidation.parse(req.params);
await this.proxiesService.deleteProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy deleted successfully",
});
});
}
export default ProxyController;
@@ -5,7 +5,6 @@ import { sendTestEmailBodyValidation } from "@/api/validation/notificationValida
import { AppError } from "@/utils/AppError.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { IEmailService } from "@/service/emailService.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
export interface ISettingsController {
@@ -17,23 +16,18 @@ export interface ISettingsController {
class SettingsController implements ISettingsController {
private settingsService: ISettingsService;
private emailService: IEmailService;
private proxiesService: IProxiesService;
constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) {
constructor(settingsService: ISettingsService, emailService: IEmailService) {
this.settingsService = settingsService;
this.emailService = emailService;
this.proxiesService = proxiesService;
}
buildAppSettings = async (dbSettings: Settings) => {
buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
delete sanitizedSettings.version;
delete sanitizedSettings.jwtSecret;
const globalProxy =
dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null;
const returnSettings: Record<string, unknown | null> = {
pagespeedKeySet: false,
emailPasswordSet: false,
globalProxy,
settings: null,
};
@@ -52,7 +46,7 @@ class SettingsController implements ISettingsController {
getAppSettings = catchAsync(async (req: Request, res: Response) => {
const dbSettings = await this.settingsService.getDBSettings();
const returnSettings = await this.buildAppSettings(dbSettings);
const returnSettings = this.buildAppSettings(dbSettings);
return res.status(200).json({
success: true,
msg: "App settings fetched successfully",
@@ -63,15 +57,8 @@ class SettingsController implements ISettingsController {
updateAppSettings = catchAsync(async (req: Request, res: Response) => {
const validatedBody = updateAppSettingsBodyValidation.parse(req.body);
if (validatedBody.globalProxyId) {
const proxy = await this.proxiesService.getProxySummary(validatedBody.globalProxyId);
if (!proxy) {
throw new AppError({ message: "Referenced proxy does not exist", status: 422 });
}
}
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
const returnSettings = await this.buildAppSettings(updatedSettings);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
msg: "App settings updated successfully",
@@ -7,7 +7,7 @@ const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
// the status page on top of the global helmet policy, blocking external images,
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
const STATUS_PAGE_CSP = [
"img-src 'self' data: blob:",
"img-src 'self' data:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");
-14
View File
@@ -1,14 +0,0 @@
import { IProxiesController } from "@/api/controllers/proxyController.js";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createProxyRoutes = (proxyController: IProxiesController): Router => {
const router = Router();
router.post("/", proxyController.createProxy);
router.get("/", isAllowed(["admin", "superadmin"]), proxyController.getAllProxies);
router.get("/team", proxyController.getProxiesByTeamId);
router.get("/:id", proxyController.getProxyById);
router.delete("/:id", proxyController.deleteProxy);
router.patch("/:id", proxyController.editProxy);
return router;
};
-1
View File
@@ -22,4 +22,3 @@ export * from "./notificationValidation.js";
export * from "./userValidation.js";
export * from "./incidentValidation.js";
export * from "./tagValidation.js";
export * from "./proxyValidation.js";
+3 -32
View File
@@ -10,19 +10,11 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
// The client form submits proxyId: "" when no proxy is selected, set it to undefined
const proxyIdValidation = z
.string()
.optional()
.transform((value) => (value === "" ? undefined : value))
.refine((value) => value === undefined || /^[0-9a-f]{24}$/i.test(value), { message: "Invalid proxy ID" });
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
@@ -108,16 +100,6 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole
}
};
const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => {
if (body.proxyMode === "custom" && !body.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -128,8 +110,6 @@ export const createMonitorBodyValidation = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
url: z.string().min(1, "URL is required"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
isActive: z.boolean().optional(),
@@ -160,8 +140,7 @@ export const createMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const editMonitorBodyValidation = z
.object({
@@ -177,8 +156,6 @@ export const editMonitorBodyValidation = z
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -203,8 +180,7 @@ export const editMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -239,8 +215,6 @@ const importedMonitorSchema = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
type: z.enum(MonitorTypes, "Invalid monitor type"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -279,8 +253,7 @@ const importedMonitorSchema = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
.superRefine(refineRegexPattern);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -312,8 +285,6 @@ export const monitorResponseSchema = z
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
proxyMode: z.enum(ProxyModes),
proxyId: proxyIdValidation,
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
@@ -1,45 +0,0 @@
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
import { z } from "zod";
//****************************************
// Proxy Validations
//****************************************
export const createProxyBodyValidation = z.object({
name: z.string().min(1, "Proxy name is required"),
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
host: z.string().min(1, "Proxy host is required"),
port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"),
username: z.string().optional(),
password: z.string().optional(),
});
export const editProxyBodyValidation = createProxyBodyValidation.extend({
clearPassword: z.boolean().optional(),
clearUsername: z.boolean().optional(),
});
export const getProxyByIdParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const editProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const deleteProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const proxyResponseSchema = z
.object({
id: z.string(),
teamId: z.string(),
name: z.string(),
protocol: z.enum(ProxyProtocols),
host: z.string(),
port: z.number(),
username: z.string().optional(),
hasPassword: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
@@ -40,16 +40,5 @@ export const updateAppSettingsBodyValidation = z
temperature: z.number().min(1).max(150).optional(),
})
.optional(),
globalProxyEnabled: z.boolean().optional(),
globalProxyId: z.string().nullable().optional(),
})
.strip()
.superRefine((body, ctx) => {
if (body.globalProxyEnabled === true && !body.globalProxyId) {
ctx.addIssue({
code: "custom",
path: ["globalProxyId"],
message: "A proxy must be selected to enable the global proxy",
});
}
});
.strip();
+1 -1
View File
@@ -76,7 +76,7 @@ export const createApp = ({
directives: {
upgradeInsecureRequests: null,
"script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
"img-src": ["'self'", "data:", "blob:", "https://img.shields.io"],
"img-src": ["'self'", "data:", "https://img.shields.io"],
"object-src": ["'none'"],
"base-uri": ["'self'"],
},
+1 -4
View File
@@ -12,7 +12,6 @@ import NotificationController from "../api/controllers/notificationController.js
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import ProxyController from "@/api/controllers/proxyController.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
@@ -30,13 +29,12 @@ export interface InitializedControllers {
tagController: TagsController;
diagnosticController: DiagnosticController;
incidentController: IncidentController;
proxyController: ProxyController;
}
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
return {
authController: new AuthController(apiServices.userService),
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService),
checkController: new CheckController(apiServices.checkService),
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
inviteController: new InviteController(apiServices.inviteService),
@@ -48,6 +46,5 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
tagController: new TagsController(apiServices.tagsService),
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
proxyController: new ProxyController(apiServices.proxiesService),
};
};
+11
View File
@@ -34,6 +34,17 @@ const envSchema = z.object({
// Optional
ORIGIN: z.string().optional(),
// Outbound proxy for monitor checks. HTTP_PROXY/HTTPS_PROXY/NO_PROXY follow
// the conventional semantics (NO_PROXY supports wildcards and host lists) and
// are read by proxy-from-env directly from the environment.
HTTP_PROXY: z.string().optional(),
HTTPS_PROXY: z.string().optional(),
NO_PROXY: z.string().optional(),
// Comma-separated monitor types to route through the proxy. Defaults to
// "http,pagespeed"; "hardware" is excluded because Capture agents normally
// live on the internal network. Set explicitly to override.
PROXY_MONITOR_TYPES: z.string().optional(),
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
});
-3
View File
@@ -19,7 +19,6 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js";
import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js";
import { createTagRoutes } from "@/api/routes/tagRoutes.js";
import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js";
import { createProxyRoutes } from "@/api/routes/proxyRoutes.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
@@ -38,7 +37,6 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const tagRoutes = createTagRoutes(controllers.tagController);
const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT);
const incidentRoutes = createIncidentRoutes(controllers.incidentController);
const proxyRoutes = createProxyRoutes(controllers.proxyController);
app.use("/api/v1/auth", authApiLimiter, authRoutes);
app.use("/api/v1/monitors", verifyJWT, monitorRoutes);
@@ -54,5 +52,4 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/tags", verifyJWT, tagRoutes);
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes);
app.use("/api/v1/incidents", verifyJWT, incidentRoutes);
app.use("/api/v1/proxies", verifyJWT, proxyRoutes);
};
+3 -6
View File
@@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { SharedServices } from "@/config/services.shared.js";
export interface ApiServices extends SharedServices {
worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only)
userService: IUserService;
@@ -23,7 +23,6 @@ export interface ApiServices extends SharedServices {
statusPageService: IStatusPageService;
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -46,7 +45,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
teamsRepository,
maintenanceWindowsRepository,
jobsRepository,
proxiesRepository,
} = shared;
const userService = new UserService({
@@ -96,7 +94,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
return {
...shared,
worker: jobScheduler,
@@ -107,6 +105,5 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
statusPageService,
tagsService,
diagnosticService,
proxiesService,
};
};
-5
View File
@@ -52,7 +52,6 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
@@ -71,7 +70,6 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -105,7 +103,6 @@ export interface SharedServices {
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
proxiesRepository: IProxiesRepository;
}
export const buildShared = async ({
@@ -142,7 +139,6 @@ export const buildShared = async ({
const tagsRepository = new MongoTagsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const proxiesRepository = new MongoProxiesRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
@@ -225,7 +221,6 @@ export const buildShared = async ({
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
};
return sharedServices;
};
+3 -13
View File
@@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
@@ -64,14 +64,13 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
} = shared;
// ***********************
// Network providers
// ***********************
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath), envSettings.proxyMonitorTypes, logger);
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
@@ -94,7 +93,6 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
dnsProvider,
]);
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
@@ -112,15 +110,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
// Check producer/evaluator
// Handles creating and evaluatiog checks
// ***********************
const checkProducer = new CheckProducer(
monitorsRepository,
maintenanceWindowsRepository,
checkService,
networkService,
proxyResolver,
bufferService,
logger
);
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
@@ -1,27 +0,0 @@
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Backfill proxyMode on monitors created before the field existed.
*
* Mongoose defaults only apply to new documents, and monitorResponseSchema
* requires proxyMode on every monitor read.
*/
export async function backfillMonitorProxyMode(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:BackfillMonitorProxyMode";
try {
logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" });
const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } });
logger.info({
service: SERVICE_NAME,
message: `Backfilled proxyMode on ${result.modifiedCount} monitors`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` });
throw error;
}
}
-2
View File
@@ -9,7 +9,6 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import { slimRecentChecks } from "./0010_slimRecentChecks.js";
import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -28,7 +27,6 @@ const migrations: MigrationEntry[] = [
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
{ name: "0010_slimRecentChecks", execute: slimRecentChecks },
{ name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode },
];
const runMigrations = async (logger: ILogger) => {
@@ -40,8 +40,6 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema },
globalProxyEnabled: { type: Boolean, default: false },
globalProxyId: { type: String, default: null },
},
{ timestamps: true }
);
@@ -28,8 +28,6 @@ class MongoSettingsRepository implements ISettingsRepository {
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
globalProxyEnabled: doc.globalProxyEnabled ?? false,
globalProxyId: doc.globalProxyId ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -16,6 +16,8 @@ export type EnvConfig = {
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
/** Comma-separated monitor types routed through the outbound proxy, if set. */
proxyMonitorTypes?: string;
clientConfig: ClientRuntimeConfig;
};
@@ -43,6 +45,7 @@ export class SettingsService implements ISettingsService {
queueMode: env.QUEUE_MODE,
queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
proxyMonitorTypes: env.PROXY_MONITOR_TYPES,
clientConfig: {
...(env.CLIENT_CONFIG_API_BASE_URL && { apiBaseUrl: env.CLIENT_CONFIG_API_BASE_URL }),
...(env.CLIENT_CONFIG_CLIENT_HOST && { clientHost: env.CLIENT_CONFIG_CLIENT_HOST }),
@@ -49,8 +49,6 @@ export interface Settings {
singleton: boolean;
version: number;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -25,7 +25,7 @@ import { CheckFilter, DateRange } from "@/types/query.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
const SERVICE_NAME = "ChecksRepository";
const SERVICE_NAME = "StatusService";
class MongoChecksRepository implements IChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
@@ -213,9 +213,6 @@ class MongoChecksRepository implements IChecksRepository {
};
private filterToMatch = (filter: CheckFilter | undefined): Record<string, unknown> => {
if (filter === undefined) {
return {};
}
switch (filter) {
case "up":
return { status: true };
@@ -546,33 +543,35 @@ class MongoChecksRepository implements IChecksRepository {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map((metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
const checks = (hardwareMetrics ?? []).map(
(metric): HardwareCheckStats => ({
bucketDate: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
})
);
return {
monitorType: "hardware" as const,
+2 -13
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import type {
CheckAudits,
ILighthouseAudit,
@@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Dat
type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
@@ -22,7 +22,6 @@ type MonitorDocumentBase = Omit<
tags: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
proxyId?: Types.ObjectId;
};
interface MonitorDocument extends MonitorDocumentBase {
@@ -171,16 +170,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
proxyMode: {
type: String,
enum: ProxyModes,
default: "inherit",
},
proxyId: {
type: Schema.Types.ObjectId,
ref: "Proxy",
},
useAdvancedMatching: {
type: Boolean,
default: false,
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
updateStatusWindowAndChecks(
monitorId: string,
@@ -53,7 +53,6 @@ export interface IMonitorsRepository {
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
@@ -172,19 +172,16 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
findMonitorCountByProxyId = async (proxyId: string): Promise<number> => {
const count = await MonitorModel.countDocuments({ proxyId });
return count;
};
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>, options?: { unsetProxyId?: boolean }) => {
const fields: Partial<Monitor> = { ...patch };
const update: Record<string, unknown> = { $set: fields };
if (options?.unsetProxyId) {
delete fields.proxyId;
update.$unset = { proxyId: "" };
}
const updatedMonitor = await MonitorModel.findOneAndUpdate({ _id: monitorId, teamId }, update, { new: true, runValidators: true });
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
{
$set: {
...patch,
},
},
{ new: true, runValidators: true }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
}
@@ -421,8 +418,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
proxyMode: doc.proxyMode ?? "inherit",
proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined,
useAdvancedMatching: doc.useAdvancedMatching ?? false,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
+1 -10
View File
@@ -133,10 +133,6 @@ export class MonitorService implements IMonitorService {
}
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
@@ -379,12 +375,7 @@ export class MonitorService implements IMonitorService {
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
// Moving off custom mode orphans the stored proxyId. Unset it so the stale reference doesn't block deleting that proxy
const unsetProxyId = body.proxyMode !== undefined && body.proxyMode !== "custom";
if (unsetProxyId) {
delete body.proxyId;
}
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
};
@@ -41,9 +41,6 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const UptimeDetailsSupportedTypes = [
"http",
"ping",
@@ -84,8 +81,6 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
-38
View File
@@ -1,38 +0,0 @@
import { Schema, model, type Types } from "mongoose";
import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js";
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
type ProxyDocumentBase = Omit<ProxyEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
interface ProxyDocument extends ProxyDocumentBase {
_id: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const ProxySchema = new Schema<ProxyDocument>(
{
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
name: { type: String, required: true },
protocol: { type: String, enum: ProxyProtocols, required: true },
host: { type: String, required: true },
port: { type: Number, required: true },
username: { type: String, required: false },
password: { type: String, required: false },
},
{ timestamps: true }
);
ProxySchema.index({ teamId: 1, name: 1 }, { unique: true });
const ProxyModel = model<ProxyDocument>("Proxy", ProxySchema);
export type { ProxyDocument };
export { ProxyModel };
export default ProxyModel;
@@ -1,15 +0,0 @@
import { Proxy } from "@/domain/proxies/proxy.type.js";
export interface IProxiesRepository {
// create
create(proxyData: Partial<Proxy>): Promise<Proxy>;
// read
findAll(): Promise<Proxy[]>;
findById(proxyId: string, teamId: string): Promise<Proxy>;
findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null>;
findByTeamId(teamId: string): Promise<Proxy[]>;
// update
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
// delete
deleteById(proxyId: string, teamId: string): Promise<Proxy>;
}
@@ -1,112 +0,0 @@
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { Proxy } from "@/domain/proxies/proxy.type.js";
import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js";
import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { Error as MongooseError, UpdateQuery } from "mongoose";
const SERVICE_NAME = "ProxiesRepository";
class MongoProxiesRepository implements IProxiesRepository {
static SERVICE_NAME = SERVICE_NAME;
private toEntity = (doc: ProxyDocument): Proxy => {
return {
id: toStringId(doc._id),
teamId: toStringId(doc.teamId),
name: doc.name,
protocol: doc.protocol,
host: doc.host,
port: doc.port,
username: doc.username,
password: doc.password,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
async create(proxyData: Partial<Proxy>): Promise<Proxy> {
try {
const proxy = await ProxyModel.create({ ...proxyData });
return this.toEntity(proxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async findAll(): Promise<Proxy[]> {
const proxies = await ProxyModel.find();
return proxies.map(this.toEntity);
}
async findById(proxyId: string, teamId: string): Promise<Proxy> {
const proxy = await ProxyModel.findOne({ _id: proxyId, teamId });
if (!proxy) {
throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(proxy);
}
async findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null> {
try {
const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId });
return proxy ? this.toEntity(proxy) : null;
} catch (error) {
if (error instanceof MongooseError.CastError) {
return null;
}
throw error;
}
}
async findByTeamId(teamId: string): Promise<Proxy[]> {
const proxies = await ProxyModel.find({ teamId });
return proxies.map(this.toEntity);
}
async updateById(
proxyId: string,
teamId: string,
patch: Partial<Proxy>,
options?: { unsetPassword?: boolean; unsetUsername?: boolean }
): Promise<Proxy> {
try {
const update: UpdateQuery<ProxyDocument> = {
$set: {
...patch,
},
};
if (options?.unsetPassword) {
delete update.$set?.password;
update.$unset = { ...update.$unset, password: 1 };
}
if (options?.unsetUsername) {
delete update.$set?.username;
update.$unset = { ...update.$unset, username: 1 };
}
const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true });
if (!updatedProxy) {
throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(updatedProxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async deleteById(proxyId: string, teamId: string): Promise<Proxy> {
const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId });
if (!deletedProxy) {
throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(deletedProxy);
}
}
export default MongoProxiesRepository;
-111
View File
@@ -1,111 +0,0 @@
import { Proxy, ProxyResponse, ProxySummary } from "@/domain/proxies/proxy.type.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { AppError } from "@/utils/AppError.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
export interface IProxiesService {
createProxy(proxy: Partial<Proxy>, teamId: string): Promise<ProxyResponse>;
getProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
getProxies(): Promise<ProxyResponse[]>;
getProxySummary(proxyId: string): Promise<ProxySummary | null>;
getProxiesByTeamId(teamId: string): Promise<ProxyResponse[]>;
updateProxy(proxyId: string, teamId: string, patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }): Promise<ProxyResponse>;
deleteProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
}
const SERVICE_NAME = "ProxiesService";
export class ProxiesService implements IProxiesService {
constructor(
private proxiesRepository: IProxiesRepository,
private monitorsRepository: IMonitorsRepository,
private settingsService: ISettingsService
) {}
private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) });
createProxy = async (proxyData: Partial<Proxy>, teamId: string): Promise<ProxyResponse> => {
proxyData.teamId = teamId;
if (!proxyData.password) {
delete proxyData.password;
}
if (!proxyData.username) {
delete proxyData.username;
}
const raw = await this.proxiesRepository.create(proxyData);
return this.toResponse(raw);
};
getProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
const raw = await this.proxiesRepository.findById(proxyId, teamId);
return this.toResponse(raw);
};
getProxies = async (): Promise<ProxyResponse[]> => {
const raw = await this.proxiesRepository.findAll();
const clean = raw.map((r) => {
return this.toResponse(r);
});
return clean;
};
getProxySummary = async (proxyId: string): Promise<ProxySummary | null> => {
const proxy = await this.proxiesRepository.findByIdOrNull(proxyId);
if (!proxy) {
return null;
}
const { id, name, host, port } = proxy;
return { id, name, host, port };
};
getProxiesByTeamId = async (teamId: string): Promise<ProxyResponse[]> => {
const raw = await this.proxiesRepository.findByTeamId(teamId);
const clean = raw.map((r) => {
return this.toResponse(r);
});
return clean;
};
updateProxy = async (
proxyId: string,
teamId: string,
patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }
): Promise<ProxyResponse> => {
// Empty/absent password and username don't delete, clearPassword / clearUsername does
const { clearPassword, clearUsername, ...proxyPatch } = patch;
if (!proxyPatch.password) {
delete proxyPatch.password;
}
if (!proxyPatch.username) {
delete proxyPatch.username;
}
const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, {
unsetPassword: clearPassword === true,
unsetUsername: clearUsername === true,
});
return this.toResponse(raw);
};
deleteProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
// Make sure proxy is not in use
const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId);
if (monitorsUsingProxy > 0) {
throw new AppError({
message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`,
service: SERVICE_NAME,
status: 409,
});
}
// Make sure this is not set as a global proxy
const settings = await this.settingsService.getDBSettings();
if (settings.globalProxyId === proxyId) {
throw new AppError({
message: "Proxy is set as the global proxy in settings",
service: SERVICE_NAME,
status: 409,
});
}
const raw = await this.proxiesRepository.deleteById(proxyId, teamId);
return this.toResponse(raw);
};
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more