Compare commits
113 Commits
8c94a30a81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 597579376f | |||
| f25792b8d8 | |||
| 6093c5dea8 | |||
| 84bc7b0384 | |||
| 3932aa56f7 | |||
| 9657bd7a36 | |||
| 574e2eb9a5 | |||
| 21a8023bf1 | |||
| 328f69ea5e | |||
| c0077e3dd8 | |||
| af6ea11079 | |||
| 5a7f32541f | |||
| dd3f18bb06 | |||
| f4b18b6cf1 | |||
| a220e5de99 | |||
| a5ffafaf9e | |||
| d17752b611 | |||
| fe05c40426 | |||
| 5a0478f47d | |||
| 1cea82f5d9 | |||
| 418034f639 | |||
| 489dde812f | |||
| c2e4e614e0 | |||
| 344071193c | |||
| 03118e59d7 | |||
| 15fea78505 | |||
| 89d3a39679 | |||
| f614eb1cf8 | |||
| 6de3100615 | |||
| cbe1cf26fa | |||
| 5c566cd90d | |||
| 1ad237d750 | |||
| d9dfde1c87 | |||
| 117710cc0a | |||
| 9aecbea68b | |||
| cd91b20278 | |||
| fd9853957a | |||
| 9c68f11d74 | |||
| 6848a66d88 | |||
| a9a49eba4e | |||
| 9ccde7ca37 | |||
| 88c3345647 | |||
| d9f3de9468 | |||
| e62aaaf5a0 | |||
| ef84273868 | |||
| 6c59b21088 | |||
| cd1795b9ff | |||
| e0242bc823 | |||
| 75f8106ca5 | |||
| 4e4f8ad5d4 | |||
| 5c5d51350f | |||
| ba1559e790 | |||
| ab9d03be8a | |||
| 07a7236282 | |||
| bd53bce231 | |||
| 18d6289807 | |||
| e24681332d | |||
| 0445a72764 | |||
| fd24a3a548 | |||
| 7619757ed5 | |||
| 45d2173d1e | |||
| 08ee35e25f | |||
| 91642e745f | |||
| fa8958934f | |||
| 1b12c01366 | |||
| 4bc178b720 | |||
| 8e5a549c2c | |||
| c437e72c2b | |||
| e5a9acfa91 | |||
| 1bab5cd16d | |||
| 6f36f73f8e | |||
| 7599b3742d | |||
| ec05163a04 | |||
| 918bb132ef | |||
| 505b93be4f | |||
| 648ff07651 | |||
| ea26ef4d33 | |||
| 7acfb498b4 | |||
| 51eece14c2 | |||
| 882ad2dca8 | |||
| 35a76aaca6 | |||
| ff6d7998c3 | |||
| a0ba4e1314 | |||
| 9eb94545a1 | |||
| 528a720329 | |||
| 55f5652572 | |||
| d4f176c731 | |||
| 7957052172 | |||
| 5674f2ea45 | |||
| a8b7d9f54a | |||
| 2747e62ff8 | |||
| 712bec3fea | |||
| 839943d9fd | |||
| 606d113f34 | |||
| 2c727c361e | |||
| c74461ddfb | |||
| d0e8154c21 | |||
| 4035335901 | |||
| d76a838bbb | |||
| c42504beee | |||
| c6da398574 | |||
| afd30e3897 | |||
| c191a67fa7 | |||
| c63e08748c | |||
| 2400591f17 | |||
| 3e025bcf1b | |||
| 2f5529a93a | |||
| 64b4c3dfa4 | |||
| d1af2bf4c6 | |||
| 5b34c08851 | |||
| 6c660239d0 | |||
| 834c5b42b0 | |||
| 6e40d3c594 |
47
.env.example
@@ -1,29 +1,62 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
|
# DEV default only. Use strong unique credentials in production.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Never hardcode in source. Rotate regularly.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Never hardcode in source. Rotate with re-encryption plan.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
# Dev: set to * to allow all origins (credentials disabled automatically)
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Active Connection Ratio alert is only evaluated when total sessions
|
||||||
|
# are at least this number (reduces false positives on low-traffic DBs).
|
||||||
|
ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5
|
||||||
|
# Rollback Ratio tuning to reduce false positives on low traffic.
|
||||||
|
ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 8080.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
# For reverse proxy + SSL prefer relative path to avoid mixed-content.
|
|
||||||
VITE_API_URL=/api/v1
|
|
||||||
|
|||||||
158
.github/workflows/container-cve-scan-development.yml
vendored
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
name: Container CVE Scan (development)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["development"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cve-scan:
|
||||||
|
name: Scan backend/frontend images for CVEs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Docker Hub login (for Scout)
|
||||||
|
if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Prepare Docker auth config for Scout container
|
||||||
|
if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/scout-docker-config"
|
||||||
|
cp "$HOME/.docker/config.json" "$RUNNER_TEMP/scout-docker-config/config.json"
|
||||||
|
chmod 600 "$RUNNER_TEMP/scout-docker-config/config.json"
|
||||||
|
|
||||||
|
- name: Build backend image (local)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: nexapg-backend:dev-scan
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
- name: Build frontend image (local)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
file: ./frontend/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: nexapg-frontend:dev-scan
|
||||||
|
build-args: |
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
|
- name: Trivy scan (backend)
|
||||||
|
uses: aquasecurity/trivy-action@0.24.0
|
||||||
|
with:
|
||||||
|
image-ref: nexapg-backend:dev-scan
|
||||||
|
format: json
|
||||||
|
output: trivy-backend.json
|
||||||
|
severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
|
||||||
|
ignore-unfixed: false
|
||||||
|
exit-code: 0
|
||||||
|
|
||||||
|
- name: Trivy scan (frontend)
|
||||||
|
uses: aquasecurity/trivy-action@0.24.0
|
||||||
|
with:
|
||||||
|
image-ref: nexapg-frontend:dev-scan
|
||||||
|
format: json
|
||||||
|
output: trivy-frontend.json
|
||||||
|
severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
|
||||||
|
ignore-unfixed: false
|
||||||
|
exit-code: 0
|
||||||
|
|
||||||
|
- name: Summarize Trivy severities
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
def summarize(path):
|
||||||
|
c = Counter()
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for result in data.get("Results", []):
|
||||||
|
for v in result.get("Vulnerabilities", []) or []:
|
||||||
|
c[v.get("Severity", "UNKNOWN")] += 1
|
||||||
|
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]:
|
||||||
|
c.setdefault(sev, 0)
|
||||||
|
return c
|
||||||
|
|
||||||
|
for label, path in [("backend", "trivy-backend.json"), ("frontend", "trivy-frontend.json")]:
|
||||||
|
s = summarize(path)
|
||||||
|
print(f"===== Trivy {label} =====")
|
||||||
|
print(f"CRITICAL={s['CRITICAL']} HIGH={s['HIGH']} MEDIUM={s['MEDIUM']} LOW={s['LOW']} UNKNOWN={s['UNKNOWN']}")
|
||||||
|
print()
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Docker Scout scan (backend)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||||
|
echo "Docker Hub Scout scan skipped: DOCKERHUB_USERNAME/DOCKERHUB_TOKEN not set." > scout-backend.txt
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
docker run --rm \
|
||||||
|
-u root \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$RUNNER_TEMP/scout-docker-config:/root/.docker" \
|
||||||
|
-e DOCKER_CONFIG=/root/.docker \
|
||||||
|
-e DOCKER_SCOUT_HUB_USER="${{ secrets.DOCKERHUB_USERNAME }}" \
|
||||||
|
-e DOCKER_SCOUT_HUB_PASSWORD="${{ secrets.DOCKERHUB_TOKEN }}" \
|
||||||
|
docker/scout-cli:latest cves nexapg-backend:dev-scan \
|
||||||
|
--only-severity critical,high,medium,low > scout-backend.txt 2>&1 || {
|
||||||
|
echo "" >> scout-backend.txt
|
||||||
|
echo "Docker Scout backend scan failed (non-blocking)." >> scout-backend.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Docker Scout scan (frontend)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||||
|
echo "Docker Hub Scout scan skipped: DOCKERHUB_USERNAME/DOCKERHUB_TOKEN not set." > scout-frontend.txt
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
docker run --rm \
|
||||||
|
-u root \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$RUNNER_TEMP/scout-docker-config:/root/.docker" \
|
||||||
|
-e DOCKER_CONFIG=/root/.docker \
|
||||||
|
-e DOCKER_SCOUT_HUB_USER="${{ secrets.DOCKERHUB_USERNAME }}" \
|
||||||
|
-e DOCKER_SCOUT_HUB_PASSWORD="${{ secrets.DOCKERHUB_TOKEN }}" \
|
||||||
|
docker/scout-cli:latest cves nexapg-frontend:dev-scan \
|
||||||
|
--only-severity critical,high,medium,low > scout-frontend.txt 2>&1 || {
|
||||||
|
echo "" >> scout-frontend.txt
|
||||||
|
echo "Docker Scout frontend scan failed (non-blocking)." >> scout-frontend.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Print scan summary
|
||||||
|
run: |
|
||||||
|
echo "===== Docker Scout backend ====="
|
||||||
|
test -f scout-backend.txt && cat scout-backend.txt || echo "scout-backend.txt not available"
|
||||||
|
echo
|
||||||
|
echo "===== Docker Scout frontend ====="
|
||||||
|
test -f scout-frontend.txt && cat scout-frontend.txt || echo "scout-frontend.txt not available"
|
||||||
|
|
||||||
|
- name: Upload scan reports
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: container-cve-scan-reports
|
||||||
|
path: |
|
||||||
|
trivy-backend.json
|
||||||
|
trivy-frontend.json
|
||||||
|
scout-backend.txt
|
||||||
|
scout-frontend.txt
|
||||||
139
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
name: Docker Publish (Release)
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version tag to publish (e.g. 0.1.2 or v0.1.2)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build and Push Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Optional repo variable. If unset, DOCKERHUB_USERNAME is used.
|
||||||
|
IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Dependency security gate (pip-audit)
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pip-audit
|
||||||
|
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
|
||||||
|
python backend/scripts/pip_audit_gate.py \
|
||||||
|
--report pip-audit-backend.json \
|
||||||
|
--allowlist ops/security/pip-audit-allowlist.json
|
||||||
|
|
||||||
|
- name: Resolve version/tag
|
||||||
|
id: ver
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
RAW_TAG="${{ github.event.release.tag_name }}"
|
||||||
|
if [ -z "$RAW_TAG" ]; then
|
||||||
|
RAW_TAG="${{ inputs.version }}"
|
||||||
|
fi
|
||||||
|
if [ -z "$RAW_TAG" ]; then
|
||||||
|
RAW_TAG="${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLEAN_TAG="${RAW_TAG#v}"
|
||||||
|
echo "raw=$RAW_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "clean=$CLEAN_TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set image namespace
|
||||||
|
id: ns
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NS="${IMAGE_NAMESPACE}"
|
||||||
|
if [ -z "$NS" ]; then
|
||||||
|
NS="${{ secrets.DOCKERHUB_USERNAME }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Normalize accidental input like spaces or uppercase.
|
||||||
|
NS="$(echo "$NS" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||||
|
|
||||||
|
# Reject clearly invalid placeholders/config mistakes early.
|
||||||
|
if [ -z "$NS" ] || [ "$NS" = "-" ]; then
|
||||||
|
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Namespace must be a single Docker Hub account/org name, not a path/url.
|
||||||
|
if [[ "$NS" == *"/"* ]] || [[ "$NS" == *":"* ]]; then
|
||||||
|
echo "Invalid Docker Hub namespace '$NS'. Use only the account/org name (e.g. 'nesterovicit')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$NS" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
|
||||||
|
echo "Invalid Docker Hub namespace '$NS'. Allowed: lowercase letters, digits, ., _, -"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using Docker Hub namespace: $NS"
|
||||||
|
echo "value=$NS" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile
|
||||||
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=NexaPG Backend
|
||||||
|
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
|
||||||
|
tags: |
|
||||||
|
${{ steps.ns.outputs.value }}/nexapg-backend:${{ steps.ver.outputs.clean }}
|
||||||
|
${{ steps.ns.outputs.value }}/nexapg-backend:latest
|
||||||
|
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build and push frontend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
file: ./frontend/Dockerfile
|
||||||
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
build-args: |
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=NexaPG Frontend
|
||||||
|
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
|
||||||
|
tags: |
|
||||||
|
${{ steps.ns.outputs.value }}/nexapg-frontend:${{ steps.ver.outputs.clean }}
|
||||||
|
${{ steps.ns.outputs.value }}/nexapg-frontend:latest
|
||||||
|
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache,mode=max
|
||||||
86
.github/workflows/migration-safety.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: Migration Safety
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
migration-safety:
|
||||||
|
name: Alembic upgrade/downgrade safety
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: nexapg
|
||||||
|
POSTGRES_USER: nexapg
|
||||||
|
POSTGRES_PASSWORD: nexapg
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U nexapg -d nexapg"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 30
|
||||||
|
|
||||||
|
env:
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: nexapg
|
||||||
|
DB_USER: nexapg
|
||||||
|
DB_PASSWORD: nexapg
|
||||||
|
JWT_SECRET_KEY: ci-jwt-secret-key
|
||||||
|
ENCRYPTION_KEY: MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install PostgreSQL client tools
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
|
- name: Wait for PostgreSQL
|
||||||
|
env:
|
||||||
|
PGPASSWORD: nexapg
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if pg_isready -h postgres -p 5432 -U nexapg -d nexapg; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "PostgreSQL did not become ready in time."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Alembic upgrade -> downgrade -1 -> upgrade
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
alembic upgrade head
|
||||||
|
alembic downgrade -1
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
- name: Validate schema consistency after roundtrip
|
||||||
|
env:
|
||||||
|
PGPASSWORD: nexapg
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
alembic upgrade head
|
||||||
|
pg_dump -h postgres -p 5432 -U nexapg -d nexapg --schema-only --no-owner --no-privileges \
|
||||||
|
| sed '/^\\restrict /d; /^\\unrestrict /d' > /tmp/schema_head_before.sql
|
||||||
|
|
||||||
|
alembic downgrade -1
|
||||||
|
alembic upgrade head
|
||||||
|
pg_dump -h postgres -p 5432 -U nexapg -d nexapg --schema-only --no-owner --no-privileges \
|
||||||
|
| sed '/^\\restrict /d; /^\\unrestrict /d' > /tmp/schema_head_after.sql
|
||||||
|
|
||||||
|
diff -u /tmp/schema_head_before.sql /tmp/schema_head_after.sql
|
||||||
72
.github/workflows/pg-compat-matrix.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: PostgreSQL Compatibility Matrix
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master", "development"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pg-compat:
|
||||||
|
name: PG${{ matrix.pg_version }} smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 3
|
||||||
|
matrix:
|
||||||
|
pg_version: ["14", "15", "16", "17", "18"]
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:${{ matrix.pg_version }}
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: compatdb
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres -d compatdb"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Enable pg_stat_statements in service container
|
||||||
|
run: |
|
||||||
|
PG_CID="$(docker ps --filter "ancestor=postgres:${{ matrix.pg_version }}" --format "{{.ID}}" | head -n1)"
|
||||||
|
if [ -z "$PG_CID" ]; then
|
||||||
|
echo "Could not find postgres service container for version ${{ matrix.pg_version }}"
|
||||||
|
docker ps -a
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using postgres container: $PG_CID"
|
||||||
|
docker exec "$PG_CID" psql -U postgres -d compatdb -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';"
|
||||||
|
docker restart "$PG_CID"
|
||||||
|
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
if docker exec "$PG_CID" pg_isready -U postgres -d compatdb; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
docker exec "$PG_CID" psql -U postgres -d compatdb -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
|
||||||
|
|
||||||
|
- name: Run PostgreSQL compatibility smoke checks
|
||||||
|
env:
|
||||||
|
PG_DSN_CANDIDATES: postgresql://postgres:postgres@postgres:5432/compatdb?sslmode=disable,postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable
|
||||||
|
run: python backend/scripts/pg_compat_smoke.py
|
||||||
35
.github/workflows/proxy-profile-validation.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Proxy Profile Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master", "development"]
|
||||||
|
paths:
|
||||||
|
- "frontend/**"
|
||||||
|
- "ops/profiles/prod/**"
|
||||||
|
- "ops/scripts/validate_proxy_profile.sh"
|
||||||
|
- ".github/workflows/proxy-profile-validation.yml"
|
||||||
|
- "README.md"
|
||||||
|
- ".env.example"
|
||||||
|
- "ops/.env.example"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "frontend/**"
|
||||||
|
- "ops/profiles/prod/**"
|
||||||
|
- "ops/scripts/validate_proxy_profile.sh"
|
||||||
|
- ".github/workflows/proxy-profile-validation.yml"
|
||||||
|
- "README.md"
|
||||||
|
- ".env.example"
|
||||||
|
- "ops/.env.example"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Validate proxy profile and mixed-content guardrails
|
||||||
|
run: bash ops/scripts/validate_proxy_profile.sh
|
||||||
53
.github/workflows/python-dependency-security.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Python Dependency Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master", "development"]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
- ".github/workflows/python-dependency-security.yml"
|
||||||
|
- "ops/security/pip-audit-allowlist.json"
|
||||||
|
- "docs/security/dependency-exceptions.md"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
- ".github/workflows/python-dependency-security.yml"
|
||||||
|
- "ops/security/pip-audit-allowlist.json"
|
||||||
|
- "docs/security/dependency-exceptions.md"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pip-audit:
|
||||||
|
name: pip-audit (block high/critical)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pip-audit
|
||||||
|
|
||||||
|
- name: Run pip-audit (JSON report)
|
||||||
|
run: |
|
||||||
|
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
|
||||||
|
|
||||||
|
- name: Enforce vulnerability policy
|
||||||
|
run: |
|
||||||
|
python backend/scripts/pip_audit_gate.py \
|
||||||
|
--report pip-audit-backend.json \
|
||||||
|
--allowlist ops/security/pip-audit-allowlist.json
|
||||||
|
|
||||||
|
- name: Upload pip-audit report
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: pip-audit-security-report
|
||||||
|
path: pip-audit-backend.json
|
||||||
3
Makefile
@@ -1,7 +1,8 @@
|
|||||||
.PHONY: up down logs migrate
|
.PHONY: up down logs migrate
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose up -d --build
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
down:
|
down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|||||||
499
README.md
@@ -1,113 +1,466 @@
|
|||||||
# NexaPG - PostgreSQL Monitoring Stack
|
# NexaPG
|
||||||
|
|
||||||
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
|
<p align="center">
|
||||||
|
<img src="frontend/public/nexapg-logo.svg" alt="NexaPG Logo" width="180" />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Features
|
NexaPG is a full-stack PostgreSQL monitoring platform for multiple remote targets.
|
||||||
|
It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, polling collectors, query insights, alerting, and target-owner email notifications.
|
||||||
|
|
||||||
- Multi-target PostgreSQL Monitoring (remote)
|
## Table of Contents
|
||||||
- Polling Collector fuer:
|
|
||||||
- `pg_stat_database`
|
|
||||||
- `pg_stat_activity`
|
|
||||||
- `pg_stat_bgwriter`
|
|
||||||
- `pg_locks`
|
|
||||||
- `pg_stat_statements` (falls auf Target aktiviert)
|
|
||||||
- Core-DB fuer:
|
|
||||||
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
|
|
||||||
- Targets (Credentials verschluesselt via Fernet)
|
|
||||||
- Metrics / Query Stats
|
|
||||||
- Audit Logs
|
|
||||||
- Auth mit JWT Access/Refresh Tokens
|
|
||||||
- FastAPI + SQLAlchemy async + Alembic
|
|
||||||
- React (Vite) Frontend mit:
|
|
||||||
- Login/Logout
|
|
||||||
- Dashboard
|
|
||||||
- Target Detail mit Charts
|
|
||||||
- Query Insights
|
|
||||||
- Admin User Management
|
|
||||||
- Health Endpoints:
|
|
||||||
- `/api/v1/healthz`
|
|
||||||
- `/api/v1/readyz`
|
|
||||||
|
|
||||||
## Struktur
|
- [Quick Deploy (Prebuilt Images)](#quick-deploy-prebuilt-images)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Make Commands](#make-commands)
|
||||||
|
- [Configuration Reference (`.env`)](#configuration-reference-env)
|
||||||
|
- [Core Functional Areas](#core-functional-areas)
|
||||||
|
- [Service Information](#service-information)
|
||||||
|
- [Target Owner Notifications](#target-owner-notifications)
|
||||||
|
- [API Overview](#api-overview)
|
||||||
|
- [API Error Format](#api-error-format)
|
||||||
|
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
||||||
|
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
||||||
|
- [Production Proxy Profile](#production-proxy-profile)
|
||||||
|
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
||||||
|
- [Dependency Exception Flow](#dependency-exception-flow)
|
||||||
|
- [Secret Management (Production)](#secret-management-production)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Security Notes](#security-notes)
|
||||||
|
|
||||||
- `backend/` FastAPI App
|
## Highlights
|
||||||
- `frontend/` React (Vite) App
|
|
||||||
- `ops/` Scripts
|
|
||||||
- `docker-compose.yml` Stack
|
|
||||||
- `.env.example` Konfigurationsvorlage
|
|
||||||
|
|
||||||
## Schnellstart
|
- Multi-target monitoring for remote PostgreSQL instances
|
||||||
|
- Optional one-click target onboarding for "all databases" discovery on an instance
|
||||||
|
- PostgreSQL compatibility support: `14`, `15`, `16`, `17`, `18`
|
||||||
|
- JWT auth (`access` + `refresh`) and RBAC (`admin`, `operator`, `viewer`)
|
||||||
|
- Polling collector for metrics, locks, activity, and optional `pg_stat_statements`
|
||||||
|
- Target detail overview (instance, storage, replication, core performance metrics)
|
||||||
|
- Alerts system:
|
||||||
|
- standard built-in alerts
|
||||||
|
- custom SQL alerts (admin/operator)
|
||||||
|
- warning + alert severities
|
||||||
|
- real-time UI updates + toast notifications
|
||||||
|
- Target owners: alert emails are sent only to responsible users assigned to a target
|
||||||
|
- SMTP settings in admin UI (send-only) with test mail support
|
||||||
|
- Structured backend logs + audit logs
|
||||||
|
|
||||||
1. Env-Datei erstellen:
|
## UI Preview
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Targets Management
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Query Insights
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Admin Settings
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Target Detail (DBA Mode)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Target Detail (Easy Mode)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `backend/` FastAPI app, SQLAlchemy async models, Alembic migrations, collector services
|
||||||
|
- `frontend/` React + Vite UI
|
||||||
|
- `ops/` helper files/scripts
|
||||||
|
- `docker-compose.yml` full local stack
|
||||||
|
- `.env.example` complete environment template
|
||||||
|
- `Makefile` common commands
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine `24+`
|
||||||
|
- Docker Compose `v2+`
|
||||||
|
- GNU Make (optional but recommended)
|
||||||
|
- Open host ports (or custom values in `.env`):
|
||||||
|
- `FRONTEND_PORT` (default `5173`)
|
||||||
|
- `BACKEND_PORT` (default `8000`)
|
||||||
|
- `DB_PORT` (default `5433`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `psql` for manual DB checks
|
||||||
|
|
||||||
|
## Quick Deploy (Prebuilt Images)
|
||||||
|
|
||||||
|
If you only want to run NexaPG from published Docker Hub images, use the bootstrap script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mkdir -p /opt/NexaPG
|
||||||
|
cd /opt/NexaPG
|
||||||
|
wget -O bootstrap-compose.sh https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/ops/scripts/bootstrap-compose.sh
|
||||||
|
chmod +x bootstrap-compose.sh
|
||||||
|
./bootstrap-compose.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads:
|
||||||
|
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `.env.example`
|
||||||
|
- `Makefile`
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate JWT secret
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
|
# generate Fernet encryption key
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# put both values into .env (JWT_SECRET_KEY / ENCRYPTION_KEY)
|
||||||
|
# note: .env is auto-created by bootstrap if it does not exist
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual download alternative:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/NexaPG
|
||||||
|
cd /opt/NexaPG
|
||||||
|
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/docker-compose.yml
|
||||||
|
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/.env.example
|
||||||
|
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/Makefile
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Fernet Key setzen:
|
`make up` pulls `nesterovicit/nexapg-backend:latest` and `nesterovicit/nexapg-frontend:latest`, then starts the stack.
|
||||||
|
|
||||||
|
Open the application:
|
||||||
|
|
||||||
|
- Frontend: `http://<SERVER_IP>:<FRONTEND_PORT>`
|
||||||
|
- API base: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
|
||||||
|
- OpenAPI: `http://<SERVER_IP>:<BACKEND_PORT>/docs`
|
||||||
|
|
||||||
|
Initial admin bootstrap user (created from `.env` if missing):
|
||||||
|
|
||||||
|
- Email: value from `INIT_ADMIN_EMAIL`
|
||||||
|
- Password: value from `INIT_ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
## Make Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
make up # pull latest images and start all services
|
||||||
|
make down # stop all services
|
||||||
|
make logs # follow compose logs
|
||||||
|
make migrate # optional/manual: run alembic upgrade head in backend container
|
||||||
```
|
```
|
||||||
|
|
||||||
Wert in `.env` bei `ENCRYPTION_KEY` eintragen.
|
Note: Migrations run automatically when the backend container starts (`entrypoint.sh`).
|
||||||
|
|
||||||
3. Stack starten:
|
## Configuration Reference (`.env`)
|
||||||
|
|
||||||
```bash
|
### Application
|
||||||
make up
|
|
||||||
```
|
|
||||||
|
|
||||||
4. URLs:
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `APP_NAME` | Application display name |
|
||||||
|
| `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) |
|
||||||
|
| `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
### Core Database
|
||||||
- Backend API: `http://localhost:8000/api/v1`
|
|
||||||
- OpenAPI: `http://localhost:8000/docs`
|
|
||||||
|
|
||||||
Default Admin (aus `.env`):
|
| Variable | Description |
|
||||||
- Email: `admin@example.com`
|
|---|---|
|
||||||
- Passwort: `ChangeMe123!`
|
| `DB_NAME` | Core metadata database name |
|
||||||
|
| `DB_USER` | Core database user |
|
||||||
|
| `DB_PASSWORD` | Core database password |
|
||||||
|
| `DB_PORT` | Host port mapped to internal PostgreSQL `5432` |
|
||||||
|
|
||||||
## Commands
|
### Backend / Security
|
||||||
|
|
||||||
```bash
|
| Variable | Description |
|
||||||
make up
|
|---|---|
|
||||||
make down
|
| `BACKEND_PORT` | Host port mapped to backend container port `8000` |
|
||||||
make logs
|
| `JWT_SECRET_KEY` | JWT signing secret |
|
||||||
make migrate
|
| `JWT_ALGORITHM` | JWT algorithm (default `HS256`) |
|
||||||
```
|
| `JWT_ACCESS_TOKEN_MINUTES` | Access token lifetime in minutes |
|
||||||
|
| `JWT_REFRESH_TOKEN_MINUTES` | Refresh token lifetime in minutes |
|
||||||
|
| `ENCRYPTION_KEY` | Fernet key for target credentials and SMTP password encryption |
|
||||||
|
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated or `*` for dev only) |
|
||||||
|
| `POLL_INTERVAL_SECONDS` | Collector polling interval |
|
||||||
|
| `INIT_ADMIN_EMAIL` | Bootstrap admin email |
|
||||||
|
| `INIT_ADMIN_PASSWORD` | Bootstrap admin password |
|
||||||
|
|
||||||
## API (Minimum)
|
### Alert Noise Tuning
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS` | Minimum total sessions required before evaluating active-connection ratio |
|
||||||
|
| `ALERT_ROLLBACK_RATIO_WINDOW_MINUTES` | Time window for rollback ratio evaluation |
|
||||||
|
| `ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS` | Minimum transaction volume before rollback ratio is evaluated |
|
||||||
|
| `ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS` | Minimum rollback count before rollback ratio is evaluated |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `FRONTEND_PORT` | Host port mapped to frontend container port `8080` |
|
||||||
|
|
||||||
|
## Core Functional Areas
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
- Create, list, edit, delete targets
|
||||||
|
- Test target connection before save
|
||||||
|
- Optional "discover all databases" mode (creates one monitored target per discovered DB)
|
||||||
|
- Configure SSL mode per target
|
||||||
|
- Toggle `pg_stat_statements` usage per target
|
||||||
|
- Assign responsible users (target owners)
|
||||||
|
|
||||||
|
### Target Details
|
||||||
|
|
||||||
|
- Database Overview section with instance, role, uptime, size, replication, and core metrics
|
||||||
|
- Metric charts with range selection and live mode
|
||||||
|
- Locks and activity tables
|
||||||
|
|
||||||
|
### Query Insights
|
||||||
|
|
||||||
|
- Uses collected `pg_stat_statements` data
|
||||||
|
- Ranking and categorization views
|
||||||
|
- Search and pagination
|
||||||
|
- Disabled automatically for targets where query insights flag is off
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
|
||||||
|
- Warning and alert severity split
|
||||||
|
- Expandable alert cards with details and recommended actions
|
||||||
|
- Custom alert definitions (SQL + thresholds)
|
||||||
|
- Real-time refresh and in-app toast notifications
|
||||||
|
|
||||||
|
### Admin Settings
|
||||||
|
|
||||||
|
- User management (RBAC)
|
||||||
|
- SMTP settings for outgoing alert mails:
|
||||||
|
- enable/disable
|
||||||
|
- host/port/auth
|
||||||
|
- STARTTLS / SSL mode
|
||||||
|
- from email + from name
|
||||||
|
- recipient test mail
|
||||||
|
|
||||||
|
### Service Information
|
||||||
|
|
||||||
|
- Sidebar entry for runtime and system details
|
||||||
|
- Displays current version, latest known version, uptime, host, and platform
|
||||||
|
- "Check for Updates" against the latest published release in the official upstream repository (`git.nesterovic.cc/nessi/NexaPG`)
|
||||||
|
- Version/update source are read-only in UI (maintainer-controlled in code/release flow)
|
||||||
|
- Local displayed version is code-defined in `backend/app/core/config.py` (`NEXAPG_VERSION`) and not configurable via `.env`
|
||||||
|
|
||||||
|
## Target Owner Notifications
|
||||||
|
|
||||||
|
Email alert routing is target-specific:
|
||||||
|
|
||||||
|
- only users assigned as owners for a target receive that target's alert emails
|
||||||
|
- supports multiple owners per target
|
||||||
|
- notification sending is throttled to reduce repeated alert spam
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
- `GET /api/v1/healthz`
|
||||||
|
- `GET /api/v1/readyz`
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
- `POST /api/v1/auth/login`
|
- `POST /api/v1/auth/login`
|
||||||
- `POST /api/v1/auth/refresh`
|
- `POST /api/v1/auth/refresh`
|
||||||
- `POST /api/v1/auth/logout`
|
- `POST /api/v1/auth/logout`
|
||||||
- `GET /api/v1/me`
|
- `GET /api/v1/me`
|
||||||
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
|
|
||||||
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
|
### Targets
|
||||||
|
|
||||||
|
- `GET /api/v1/targets`
|
||||||
|
- `POST /api/v1/targets`
|
||||||
|
- `POST /api/v1/targets/test-connection`
|
||||||
|
- `GET /api/v1/targets/{id}`
|
||||||
|
- `PUT /api/v1/targets/{id}`
|
||||||
|
- `DELETE /api/v1/targets/{id}`
|
||||||
|
- `GET /api/v1/targets/{id}/owners`
|
||||||
|
- `PUT /api/v1/targets/{id}/owners`
|
||||||
|
- `GET /api/v1/targets/owner-candidates`
|
||||||
|
- `GET /api/v1/targets/{id}/metrics`
|
||||||
- `GET /api/v1/targets/{id}/locks`
|
- `GET /api/v1/targets/{id}/locks`
|
||||||
- `GET /api/v1/targets/{id}/activity`
|
- `GET /api/v1/targets/{id}/activity`
|
||||||
- `GET /api/v1/targets/{id}/top-queries`
|
- `GET /api/v1/targets/{id}/top-queries`
|
||||||
- Admin-only CRUD users:
|
- `GET /api/v1/targets/{id}/overview`
|
||||||
- `GET /api/v1/admin/users`
|
|
||||||
- `POST /api/v1/admin/users`
|
|
||||||
- `PUT /api/v1/admin/users/{user_id}`
|
|
||||||
- `DELETE /api/v1/admin/users/{user_id}`
|
|
||||||
|
|
||||||
## Security Notes
|
### Alerts
|
||||||
|
|
||||||
- Keine Secrets hardcoded
|
- `GET /api/v1/alerts/status`
|
||||||
- Passwoerter als Argon2 Hash
|
- `GET /api/v1/alerts/definitions`
|
||||||
- Target-Credentials verschluesselt (Fernet)
|
- `POST /api/v1/alerts/definitions`
|
||||||
- CORS via Env steuerbar
|
- `PUT /api/v1/alerts/definitions/{id}`
|
||||||
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
|
- `DELETE /api/v1/alerts/definitions/{id}`
|
||||||
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
|
- `POST /api/v1/alerts/definitions/test`
|
||||||
|
|
||||||
## Wichtiger Hinweis zu `pg_stat_statements`
|
### Admin
|
||||||
|
|
||||||
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
|
- `GET /api/v1/admin/users`
|
||||||
Beispiel:
|
- `POST /api/v1/admin/users`
|
||||||
|
- `PUT /api/v1/admin/users/{user_id}`
|
||||||
|
- `DELETE /api/v1/admin/users/{user_id}`
|
||||||
|
- `GET /api/v1/admin/settings/email`
|
||||||
|
- `PUT /api/v1/admin/settings/email`
|
||||||
|
- `POST /api/v1/admin/settings/email/test`
|
||||||
|
|
||||||
|
### Service Information
|
||||||
|
|
||||||
|
- `GET /api/v1/service/info`
|
||||||
|
- `POST /api/v1/service/info/check`
|
||||||
|
|
||||||
|
## API Error Format
|
||||||
|
|
||||||
|
All 4xx/5xx responses use a consistent JSON payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Request validation failed",
|
||||||
|
"details": [],
|
||||||
|
"request_id": "c8f0f888-2365-4b86-a5de-b3f0e9df4a4b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
- `code`: stable machine-readable error code
|
||||||
|
- `message`: human-readable summary
|
||||||
|
- `details`: optional extra context (validation list, debug context, etc.)
|
||||||
|
- `request_id`: request correlation ID (also returned in `X-Request-ID` header)
|
||||||
|
|
||||||
|
Common error codes:
|
||||||
|
|
||||||
|
- `bad_request` (`400`)
|
||||||
|
- `unauthorized` (`401`)
|
||||||
|
- `forbidden` (`403`)
|
||||||
|
- `not_found` (`404`)
|
||||||
|
- `conflict` (`409`)
|
||||||
|
- `validation_error` (`422`)
|
||||||
|
- `target_unreachable` (`503`)
|
||||||
|
- `internal_error` (`500`)
|
||||||
|
|
||||||
|
## `pg_stat_statements` Requirement
|
||||||
|
|
||||||
|
Query Insights requires `pg_stat_statements` on the monitored target:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If unavailable, disable it per target in target settings.
|
||||||
|
|
||||||
|
## Reverse Proxy / SSL Guidance
|
||||||
|
|
||||||
|
For production, serve frontend and API under the same public origin via reverse proxy.
|
||||||
|
|
||||||
|
- Frontend URL example: `https://monitor.example.com`
|
||||||
|
- Proxy API path `/api/` to backend service
|
||||||
|
- Route `/api/v1` to the backend service
|
||||||
|
|
||||||
|
This prevents mixed-content and CORS issues.
|
||||||
|
|
||||||
|
## Production Proxy Profile
|
||||||
|
|
||||||
|
A secure, repeatable production profile is included:
|
||||||
|
|
||||||
|
- `ops/profiles/prod/.env.production.example`
|
||||||
|
- `ops/profiles/prod/nginx/nexapg.conf`
|
||||||
|
- `docs/deployment/proxy-production-profile.md`
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- explicit CORS recommendations per environment (`dev`, `staging`, `prod`)
|
||||||
|
- required reverse-proxy header forwarding for backend context
|
||||||
|
- API path forwarding (`/api/` -> backend)
|
||||||
|
- mixed-content prevention guidance for HTTPS deployments
|
||||||
|
|
||||||
|
## PostgreSQL Compatibility Smoke Test
|
||||||
|
|
||||||
|
Run manually against one DSN:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_DSN='postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
|
||||||
|
python backend/scripts/pg_compat_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with DSN candidates (CI style):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PG_DSN_CANDIDATES='postgresql://postgres:postgres@postgres:5432/compatdb?sslmode=disable,postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
|
||||||
|
python backend/scripts/pg_compat_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Exception Flow
|
||||||
|
|
||||||
|
Python dependency vulnerabilities are enforced by CI via `pip-audit`.
|
||||||
|
|
||||||
|
- CI blocks unresolved `HIGH` and `CRITICAL` findings.
|
||||||
|
- Missing severity metadata is treated conservatively as `HIGH`.
|
||||||
|
- Temporary exceptions must be declared in `ops/security/pip-audit-allowlist.json`.
|
||||||
|
- Full process and required metadata are documented in:
|
||||||
|
- `docs/security/dependency-exceptions.md`
|
||||||
|
|
||||||
|
## Secret Management (Production)
|
||||||
|
|
||||||
|
Secret handling guidance is documented in:
|
||||||
|
|
||||||
|
- `docs/security/secret-management.md`
|
||||||
|
|
||||||
|
It includes:
|
||||||
|
|
||||||
|
- secure handling for `JWT_SECRET_KEY`, `ENCRYPTION_KEY`, `DB_PASSWORD`, and SMTP credentials
|
||||||
|
- clear **Do / Don't** rules
|
||||||
|
- recommended secret provider patterns (Vault/cloud/orchestrator/CI injection)
|
||||||
|
- practical rotation basics and operational checklist
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend container keeps restarting during `make migrate`
|
||||||
|
|
||||||
|
Most common reason: failed migration. Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=200 backend
|
||||||
|
docker compose logs --tail=200 db
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS or mixed-content issues behind SSL proxy
|
||||||
|
|
||||||
|
- Ensure proxy forwards `/api/` (or `/api/v1`) to backend
|
||||||
|
- Set correct frontend origin(s) in `CORS_ORIGINS`
|
||||||
|
|
||||||
|
### `rejected SSL upgrade` for a target
|
||||||
|
|
||||||
|
Target likely does not support SSL with current settings.
|
||||||
|
Set target `sslmode` to `disable` (or correct SSL config on target DB).
|
||||||
|
|
||||||
|
### Query Insights empty
|
||||||
|
|
||||||
|
- Check target has `Use pg_stat_statements` enabled
|
||||||
|
- Verify extension exists on target (`CREATE EXTENSION ...`)
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- No secrets hardcoded in repository
|
||||||
|
- Passwords hashed with Argon2
|
||||||
|
- Sensitive values encrypted at rest (Fernet)
|
||||||
|
- RBAC enforced on protected endpoints
|
||||||
|
- Audit logs for critical actions
|
||||||
|
- Collector error logging includes throttling to reduce repeated noise
|
||||||
|
- Production secret handling and rotation guidance:
|
||||||
|
- `docs/security/secret-management.md`
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM python:3.12-slim AS base
|
ARG PYTHON_BASE_IMAGE=python:3.13-alpine
|
||||||
|
FROM ${PYTHON_BASE_IMAGE} AS base
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
@@ -6,7 +7,17 @@ ENV PIP_NO_CACHE_DIR=1
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup --system app && adduser --system --ingroup app app
|
RUN if command -v apt-get >/dev/null 2>&1; then \
|
||||||
|
apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*; \
|
||||||
|
elif command -v apk >/dev/null 2>&1; then \
|
||||||
|
apk upgrade --no-cache; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN if addgroup --help 2>&1 | grep -q -- '--system'; then \
|
||||||
|
addgroup --system app && adduser --system --ingroup app app; \
|
||||||
|
else \
|
||||||
|
addgroup -S app && adduser -S -G app app; \
|
||||||
|
fi
|
||||||
|
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --upgrade pip && pip install -r /app/requirements.txt
|
RUN pip install --upgrade pip && pip install -r /app/requirements.txt
|
||||||
|
|||||||
43
backend/alembic/versions/0002_alert_definitions.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""add alert definitions
|
||||||
|
|
||||||
|
Revision ID: 0002_alert_definitions
|
||||||
|
Revises: 0001_init
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0002_alert_definitions"
|
||||||
|
down_revision = "0001_init"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"alert_definitions",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("target_id", sa.Integer(), sa.ForeignKey("targets.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("sql_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("comparison", sa.String(length=10), nullable=False, server_default="gte"),
|
||||||
|
sa.Column("warning_threshold", sa.Float(), nullable=True),
|
||||||
|
sa.Column("alert_threshold", sa.Float(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_alert_definitions_target_id", "alert_definitions", ["target_id"])
|
||||||
|
op.create_index("ix_alert_definitions_created_by_user_id", "alert_definitions", ["created_by_user_id"])
|
||||||
|
op.create_index("ix_alert_definitions_created_at", "alert_definitions", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_alert_definitions_created_at", table_name="alert_definitions")
|
||||||
|
op.drop_index("ix_alert_definitions_created_by_user_id", table_name="alert_definitions")
|
||||||
|
op.drop_index("ix_alert_definitions_target_id", table_name="alert_definitions")
|
||||||
|
op.drop_table("alert_definitions")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add target pg_stat_statements flag
|
||||||
|
|
||||||
|
Revision ID: 0003_pg_stat_statements_flag
|
||||||
|
Revises: 0002_alert_definitions
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0003_pg_stat_statements_flag"
|
||||||
|
down_revision = "0002_alert_definitions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"targets",
|
||||||
|
sa.Column("use_pg_stat_statements", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("targets", "use_pg_stat_statements")
|
||||||
38
backend/alembic/versions/0004_email_settings.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""add email notification settings
|
||||||
|
|
||||||
|
Revision ID: 0004_email_settings
|
||||||
|
Revises: 0003_pg_stat_statements_flag
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0004_email_settings"
|
||||||
|
down_revision = "0003_pg_stat_statements_flag"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"email_notification_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("smtp_host", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")),
|
||||||
|
sa.Column("smtp_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("encrypted_smtp_password", sa.Text(), nullable=True),
|
||||||
|
sa.Column("from_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("use_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("alert_recipients", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("email_notification_settings")
|
||||||
58
backend/alembic/versions/0005_target_owners_notifications.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""add target owners and alert notification events
|
||||||
|
|
||||||
|
Revision ID: 0005_target_owners
|
||||||
|
Revises: 0004_email_settings
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0005_target_owners"
|
||||||
|
down_revision = "0004_email_settings"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"target_owners",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["target_id"], ["targets.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_target_owners_target_id"), "target_owners", ["target_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_target_owners_user_id"), "target_owners", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"alert_notification_events",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("alert_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("last_sent_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["target_id"], ["targets.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_alert_notification_events_alert_key"), "alert_notification_events", ["alert_key"], unique=False)
|
||||||
|
op.create_index(op.f("ix_alert_notification_events_target_id"), "alert_notification_events", ["target_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_alert_notification_events_target_id"), table_name="alert_notification_events")
|
||||||
|
op.drop_index(op.f("ix_alert_notification_events_alert_key"), table_name="alert_notification_events")
|
||||||
|
op.drop_table("alert_notification_events")
|
||||||
|
|
||||||
|
op.drop_index(op.f("ix_target_owners_user_id"), table_name="target_owners")
|
||||||
|
op.drop_index(op.f("ix_target_owners_target_id"), table_name="target_owners")
|
||||||
|
op.drop_table("target_owners")
|
||||||
23
backend/alembic/versions/0006_email_from_name.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""add from_name to email settings
|
||||||
|
|
||||||
|
Revision ID: 0006_email_from_name
|
||||||
|
Revises: 0005_target_owners
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0006_email_from_name"
|
||||||
|
down_revision = "0005_target_owners"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("email_notification_settings", sa.Column("from_name", sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("email_notification_settings", "from_name")
|
||||||
31
backend/alembic/versions/0007_email_templates.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""email templates and drop recipients list
|
||||||
|
|
||||||
|
Revision ID: 0007_email_templates
|
||||||
|
Revises: 0006_email_from_name
|
||||||
|
Create Date: 2026-02-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0007_email_templates"
|
||||||
|
down_revision = "0006_email_from_name"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("email_notification_settings", sa.Column("warning_subject_template", sa.Text(), nullable=True))
|
||||||
|
op.add_column("email_notification_settings", sa.Column("alert_subject_template", sa.Text(), nullable=True))
|
||||||
|
op.add_column("email_notification_settings", sa.Column("warning_body_template", sa.Text(), nullable=True))
|
||||||
|
op.add_column("email_notification_settings", sa.Column("alert_body_template", sa.Text(), nullable=True))
|
||||||
|
op.drop_column("email_notification_settings", "alert_recipients")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("email_notification_settings", sa.Column("alert_recipients", sa.JSON(), nullable=False, server_default="[]"))
|
||||||
|
op.drop_column("email_notification_settings", "alert_body_template")
|
||||||
|
op.drop_column("email_notification_settings", "warning_body_template")
|
||||||
|
op.drop_column("email_notification_settings", "alert_subject_template")
|
||||||
|
op.drop_column("email_notification_settings", "warning_subject_template")
|
||||||
34
backend/alembic/versions/0008_service_settings.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""add service info settings
|
||||||
|
|
||||||
|
Revision ID: 0008_service_settings
|
||||||
|
Revises: 0007_email_templates
|
||||||
|
Create Date: 2026-02-13
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0008_service_settings"
|
||||||
|
down_revision = "0007_email_templates"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"service_info_settings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("current_version", sa.String(length=64), nullable=False, server_default="0.1.0"),
|
||||||
|
sa.Column("release_check_url", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("latest_version", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("update_available", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_check_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("service_info_settings")
|
||||||
26
backend/alembic/versions/0009_user_profile_fields.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""add user first and last name fields
|
||||||
|
|
||||||
|
Revision ID: 0009_user_profile_fields
|
||||||
|
Revises: 0008_service_settings
|
||||||
|
Create Date: 2026-02-13
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0009_user_profile_fields"
|
||||||
|
down_revision = "0008_service_settings"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("first_name", sa.String(length=120), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("last_name", sa.String(length=120), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "last_name")
|
||||||
|
op.drop_column("users", "first_name")
|
||||||
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.routes import admin_users, auth, health, me, targets
|
from app.api.routes import admin_settings, admin_users, alerts, auth, health, me, service_info, targets
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(me.router, tags=["auth"])
|
api_router.include_router(me.router, tags=["auth"])
|
||||||
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
||||||
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
|
api_router.include_router(service_info.router, prefix="/service", tags=["service"])
|
||||||
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
||||||
|
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])
|
||||||
|
|||||||
136
backend/app/api/routes/admin_settings.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.deps import require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
|
from app.models.models import EmailNotificationSettings, User
|
||||||
|
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
|
||||||
|
from app.services.audit import write_audit_log
|
||||||
|
from app.services.crypto import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings:
|
||||||
|
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
|
||||||
|
if settings:
|
||||||
|
return settings
|
||||||
|
settings = EmailNotificationSettings()
|
||||||
|
db.add(settings)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
|
||||||
|
return EmailSettingsOut(
|
||||||
|
enabled=settings.enabled,
|
||||||
|
smtp_host=settings.smtp_host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
smtp_username=settings.smtp_username,
|
||||||
|
from_name=settings.from_name,
|
||||||
|
from_email=settings.from_email,
|
||||||
|
use_starttls=settings.use_starttls,
|
||||||
|
use_ssl=settings.use_ssl,
|
||||||
|
warning_subject_template=settings.warning_subject_template,
|
||||||
|
alert_subject_template=settings.alert_subject_template,
|
||||||
|
warning_body_template=settings.warning_body_template,
|
||||||
|
alert_body_template=settings.alert_body_template,
|
||||||
|
has_password=bool(settings.encrypted_smtp_password),
|
||||||
|
updated_at=settings.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email", response_model=EmailSettingsOut)
|
||||||
|
async def get_email_settings(
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> EmailSettingsOut:
|
||||||
|
_ = admin
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
return _to_out(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/email", response_model=EmailSettingsOut)
|
||||||
|
async def update_email_settings(
|
||||||
|
payload: EmailSettingsUpdate,
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> EmailSettingsOut:
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
settings.enabled = payload.enabled
|
||||||
|
settings.smtp_host = payload.smtp_host.strip() if payload.smtp_host else None
|
||||||
|
settings.smtp_port = payload.smtp_port
|
||||||
|
settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None
|
||||||
|
settings.from_name = payload.from_name.strip() if payload.from_name else None
|
||||||
|
settings.from_email = str(payload.from_email) if payload.from_email else None
|
||||||
|
settings.use_starttls = payload.use_starttls
|
||||||
|
settings.use_ssl = payload.use_ssl
|
||||||
|
settings.warning_subject_template = payload.warning_subject_template.strip() if payload.warning_subject_template else None
|
||||||
|
settings.alert_subject_template = payload.alert_subject_template.strip() if payload.alert_subject_template else None
|
||||||
|
settings.warning_body_template = payload.warning_body_template.strip() if payload.warning_body_template else None
|
||||||
|
settings.alert_body_template = payload.alert_body_template.strip() if payload.alert_body_template else None
|
||||||
|
|
||||||
|
if payload.clear_smtp_password:
|
||||||
|
settings.encrypted_smtp_password = None
|
||||||
|
elif payload.smtp_password:
|
||||||
|
settings.encrypted_smtp_password = encrypt_secret(payload.smtp_password)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(settings)
|
||||||
|
await write_audit_log(db, "admin.email_settings.update", admin.id, {"enabled": settings.enabled})
|
||||||
|
return _to_out(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/test")
|
||||||
|
async def test_email_settings(
|
||||||
|
payload: EmailSettingsTestRequest,
|
||||||
|
admin: User = Depends(require_roles("admin")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
settings = await _get_or_create_settings(db)
|
||||||
|
if not settings.smtp_host:
|
||||||
|
raise HTTPException(status_code=400, detail=api_error("smtp_host_missing", "SMTP host is not configured"))
|
||||||
|
if not settings.from_email:
|
||||||
|
raise HTTPException(status_code=400, detail=api_error("smtp_from_email_missing", "From email is not configured"))
|
||||||
|
|
||||||
|
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = formataddr((settings.from_name, settings.from_email)) if settings.from_name else settings.from_email
|
||||||
|
message["To"] = str(payload.recipient)
|
||||||
|
message["Subject"] = payload.subject
|
||||||
|
message.set_content(payload.message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.use_ssl:
|
||||||
|
with smtplib.SMTP_SSL(
|
||||||
|
settings.smtp_host,
|
||||||
|
settings.smtp_port,
|
||||||
|
timeout=10,
|
||||||
|
context=ssl.create_default_context(),
|
||||||
|
) as smtp:
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||||
|
if settings.use_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
if settings.smtp_username:
|
||||||
|
smtp.login(settings.smtp_username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("smtp_test_failed", "SMTP test failed", {"error": str(exc)}),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
|
||||||
|
return {"status": "sent", "recipient": str(payload.recipient)}
|
||||||
@@ -3,6 +3,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import require_roles
|
from app.core.deps import require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
||||||
@@ -22,8 +23,14 @@ async def list_users(admin: User = Depends(require_roles("admin")), db: AsyncSes
|
|||||||
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
|
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
|
||||||
exists = await db.scalar(select(User).where(User.email == payload.email))
|
exists = await db.scalar(select(User).where(User.email == payload.email))
|
||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
|
||||||
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role)
|
user = User(
|
||||||
|
email=payload.email,
|
||||||
|
first_name=payload.first_name,
|
||||||
|
last_name=payload.last_name,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
role=payload.role,
|
||||||
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
@@ -40,10 +47,17 @@ async def update_user(
|
|||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
user = await db.scalar(select(User).where(User.id == user_id))
|
user = await db.scalar(select(User).where(User.id == user_id))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
if "password" in update_data and update_data["password"]:
|
next_email = update_data.get("email")
|
||||||
user.password_hash = hash_password(update_data.pop("password"))
|
if next_email and next_email != user.email:
|
||||||
|
existing = await db.scalar(select(User).where(User.email == next_email))
|
||||||
|
if existing and existing.id != user.id:
|
||||||
|
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
|
||||||
|
if "password" in update_data:
|
||||||
|
raw_password = update_data.pop("password")
|
||||||
|
if raw_password:
|
||||||
|
user.password_hash = hash_password(raw_password)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -55,10 +69,10 @@ async def update_user(
|
|||||||
@router.delete("/{user_id}")
|
@router.delete("/{user_id}")
|
||||||
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
if user_id == admin.id:
|
if user_id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
raise HTTPException(status_code=400, detail=api_error("cannot_delete_self", "Cannot delete yourself"))
|
||||||
user = await db.scalar(select(User).where(User.id == user_id))
|
user = await db.scalar(select(User).where(User.id == user_id))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
|
||||||
await db.delete(user)
|
await db.delete(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})
|
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})
|
||||||
|
|||||||
157
backend/app/api/routes/alerts.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.deps import get_current_user, require_roles
|
||||||
|
from app.core.errors import api_error
|
||||||
|
from app.models.models import AlertDefinition, Target, User
|
||||||
|
from app.schemas.alert import (
|
||||||
|
AlertDefinitionCreate,
|
||||||
|
AlertDefinitionOut,
|
||||||
|
AlertDefinitionTestRequest,
|
||||||
|
AlertDefinitionTestResponse,
|
||||||
|
AlertDefinitionUpdate,
|
||||||
|
AlertStatusResponse,
|
||||||
|
StandardAlertReferenceItem,
|
||||||
|
)
|
||||||
|
from app.services.alerts import (
|
||||||
|
get_standard_alert_reference,
|
||||||
|
get_alert_status,
|
||||||
|
invalidate_alert_cache,
|
||||||
|
run_scalar_sql_for_target,
|
||||||
|
validate_alert_sql,
|
||||||
|
validate_alert_thresholds,
|
||||||
|
)
|
||||||
|
from app.services.alert_notifications import process_target_owner_notifications
|
||||||
|
from app.services.audit import write_audit_log
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_target_exists(db: AsyncSession, target_id: int | None) -> None:
|
||||||
|
if target_id is None:
|
||||||
|
return
|
||||||
|
target_exists = await db.scalar(select(Target.id).where(Target.id == target_id))
|
||||||
|
if target_exists is None:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=AlertStatusResponse)
|
||||||
|
async def list_alert_status(
|
||||||
|
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> AlertStatusResponse:
|
||||||
|
_ = user
|
||||||
|
payload = await get_alert_status(db, use_cache=True)
|
||||||
|
await process_target_owner_notifications(db, payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/standard-reference", response_model=list[StandardAlertReferenceItem])
|
||||||
|
async def list_standard_alert_reference(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> list[StandardAlertReferenceItem]:
|
||||||
|
_ = user
|
||||||
|
return [StandardAlertReferenceItem(**item) for item in get_standard_alert_reference()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions", response_model=list[AlertDefinitionOut])
|
||||||
|
async def list_alert_definitions(
|
||||||
|
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> list[AlertDefinitionOut]:
|
||||||
|
_ = user
|
||||||
|
defs = (await db.scalars(select(AlertDefinition).order_by(AlertDefinition.id.desc()))).all()
|
||||||
|
return [AlertDefinitionOut.model_validate(item) for item in defs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/definitions", response_model=AlertDefinitionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_alert_definition(
|
||||||
|
payload: AlertDefinitionCreate,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertDefinitionOut:
|
||||||
|
await _validate_target_exists(db, payload.target_id)
|
||||||
|
sql_text = validate_alert_sql(payload.sql_text)
|
||||||
|
validate_alert_thresholds(payload.comparison, payload.warning_threshold, payload.alert_threshold)
|
||||||
|
|
||||||
|
definition = AlertDefinition(
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
target_id=payload.target_id,
|
||||||
|
sql_text=sql_text,
|
||||||
|
comparison=payload.comparison,
|
||||||
|
warning_threshold=payload.warning_threshold,
|
||||||
|
alert_threshold=payload.alert_threshold,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(definition)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(definition)
|
||||||
|
invalidate_alert_cache()
|
||||||
|
await write_audit_log(db, "alert.definition.create", user.id, {"alert_definition_id": definition.id, "name": definition.name})
|
||||||
|
return AlertDefinitionOut.model_validate(definition)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/definitions/{definition_id}", response_model=AlertDefinitionOut)
|
||||||
|
async def update_alert_definition(
|
||||||
|
definition_id: int,
|
||||||
|
payload: AlertDefinitionUpdate,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertDefinitionOut:
|
||||||
|
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
|
||||||
|
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "target_id" in updates:
|
||||||
|
await _validate_target_exists(db, updates["target_id"])
|
||||||
|
if "sql_text" in updates and updates["sql_text"] is not None:
|
||||||
|
updates["sql_text"] = validate_alert_sql(updates["sql_text"])
|
||||||
|
|
||||||
|
comparison = updates.get("comparison", definition.comparison)
|
||||||
|
warning_threshold = updates.get("warning_threshold", definition.warning_threshold)
|
||||||
|
alert_threshold = updates.get("alert_threshold", definition.alert_threshold)
|
||||||
|
validate_alert_thresholds(comparison, warning_threshold, alert_threshold)
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
setattr(definition, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(definition)
|
||||||
|
invalidate_alert_cache()
|
||||||
|
await write_audit_log(db, "alert.definition.update", user.id, {"alert_definition_id": definition.id})
|
||||||
|
return AlertDefinitionOut.model_validate(definition)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/definitions/{definition_id}")
|
||||||
|
async def delete_alert_definition(
|
||||||
|
definition_id: int,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
|
||||||
|
await db.delete(definition)
|
||||||
|
await db.commit()
|
||||||
|
invalidate_alert_cache()
|
||||||
|
await write_audit_log(db, "alert.definition.delete", user.id, {"alert_definition_id": definition_id})
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/definitions/test", response_model=AlertDefinitionTestResponse)
|
||||||
|
async def test_alert_definition(
|
||||||
|
payload: AlertDefinitionTestRequest,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertDefinitionTestResponse:
|
||||||
|
_ = user
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == payload.target_id))
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
try:
|
||||||
|
value = await run_scalar_sql_for_target(target, payload.sql_text)
|
||||||
|
return AlertDefinitionTestResponse(ok=True, value=value)
|
||||||
|
except Exception as exc:
|
||||||
|
return AlertDefinitionTestResponse(ok=False, error=str(exc))
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from jose import JWTError, jwt
|
import jwt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||||
@@ -19,7 +20,10 @@ settings = get_settings()
|
|||||||
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||||
user = await db.scalar(select(User).where(User.email == payload.email))
|
user = await db.scalar(select(User).where(User.email == payload.email))
|
||||||
if not user or not verify_password(payload.password, user.password_hash):
|
if not user or not verify_password(payload.password, user.password_hash):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_credentials", "Invalid credentials"),
|
||||||
|
)
|
||||||
|
|
||||||
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
|
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
|
||||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||||
@@ -29,15 +33,24 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> To
|
|||||||
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||||
try:
|
try:
|
||||||
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except JWTError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_refresh_token", "Invalid refresh token"),
|
||||||
|
) from exc
|
||||||
|
|
||||||
if token_payload.get("type") != "refresh":
|
if token_payload.get("type") != "refresh":
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_refresh_token_type", "Invalid refresh token type"),
|
||||||
|
)
|
||||||
user_id = token_payload.get("sub")
|
user_id = token_payload.get("sub")
|
||||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("user_not_found", "User not found"),
|
||||||
|
)
|
||||||
|
|
||||||
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
|
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
|
||||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.errors import api_error
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.user import UserOut
|
from app.schemas.user import UserOut, UserPasswordChange
|
||||||
|
from app.services.audit import write_audit_log
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -9,3 +14,27 @@ router = APIRouter()
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||||
return UserOut.model_validate(user)
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/password")
|
||||||
|
async def change_password(
|
||||||
|
payload: UserPasswordChange,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=api_error("invalid_current_password", "Current password is incorrect"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if verify_password(payload.new_password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=api_error("password_reuse_not_allowed", "New password must be different"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user.password_hash = hash_password(payload.new_password)
|
||||||
|
await db.commit()
|
||||||
|
await write_audit_log(db, action="auth.password_change", user_id=user.id, payload={})
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
94
backend/app/api/routes/service_info.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.deps import get_current_user
|
||||||
|
from app.models.models import ServiceInfoSettings, User
|
||||||
|
from app.schemas.service_info import ServiceInfoCheckResult, ServiceInfoOut
|
||||||
|
from app.services.service_info import (
|
||||||
|
UPSTREAM_REPO_WEB,
|
||||||
|
fetch_latest_from_upstream,
|
||||||
|
is_update_available,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
settings = get_settings()
|
||||||
|
service_started_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_service_settings(db: AsyncSession) -> ServiceInfoSettings:
|
||||||
|
row = await db.scalar(select(ServiceInfoSettings).limit(1))
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
row = ServiceInfoSettings(current_version=settings.app_version)
|
||||||
|
db.add(row)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(row: ServiceInfoSettings) -> ServiceInfoOut:
|
||||||
|
uptime_seconds = int((utcnow() - service_started_at).total_seconds())
|
||||||
|
return ServiceInfoOut(
|
||||||
|
app_name=settings.app_name,
|
||||||
|
environment=settings.environment,
|
||||||
|
api_prefix=settings.api_v1_prefix,
|
||||||
|
app_version=settings.app_version,
|
||||||
|
hostname=platform.node() or os.getenv("HOSTNAME", "unknown"),
|
||||||
|
python_version=platform.python_version(),
|
||||||
|
platform=platform.platform(),
|
||||||
|
service_started_at=service_started_at,
|
||||||
|
uptime_seconds=max(uptime_seconds, 0),
|
||||||
|
update_source=UPSTREAM_REPO_WEB,
|
||||||
|
latest_version=row.latest_version,
|
||||||
|
latest_ref=(row.release_check_url or None),
|
||||||
|
update_available=row.update_available,
|
||||||
|
last_checked_at=row.last_checked_at,
|
||||||
|
last_check_error=row.last_check_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info", response_model=ServiceInfoOut)
|
||||||
|
async def get_service_info(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ServiceInfoOut:
|
||||||
|
_ = user
|
||||||
|
row = await _get_or_create_service_settings(db)
|
||||||
|
return _to_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/info/check", response_model=ServiceInfoCheckResult)
|
||||||
|
async def check_service_version(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ServiceInfoCheckResult:
|
||||||
|
_ = user
|
||||||
|
row = await _get_or_create_service_settings(db)
|
||||||
|
check_time = utcnow()
|
||||||
|
latest, latest_ref, error = await fetch_latest_from_upstream()
|
||||||
|
|
||||||
|
row.last_checked_at = check_time
|
||||||
|
row.last_check_error = error
|
||||||
|
if latest:
|
||||||
|
row.latest_version = latest
|
||||||
|
row.release_check_url = latest_ref
|
||||||
|
row.update_available = is_update_available(settings.app_version, latest)
|
||||||
|
else:
|
||||||
|
row.update_available = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(row)
|
||||||
|
return ServiceInfoCheckResult(
|
||||||
|
latest_version=row.latest_version,
|
||||||
|
latest_ref=(row.release_check_url or None),
|
||||||
|
update_available=row.update_available,
|
||||||
|
last_checked_at=row.last_checked_at or check_time,
|
||||||
|
last_check_error=row.last_check_error,
|
||||||
|
)
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, desc, select
|
from sqlalchemy import and_, delete, desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user, require_roles
|
from app.core.deps import get_current_user, require_roles
|
||||||
from app.models.models import Metric, QueryStat, Target, User
|
from app.core.errors import api_error
|
||||||
|
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
|
||||||
from app.schemas.metric import MetricOut, QueryStatOut
|
from app.schemas.metric import MetricOut, QueryStatOut
|
||||||
from app.schemas.overview import DatabaseOverviewOut
|
from app.schemas.overview import DatabaseOverviewOut
|
||||||
from app.schemas.target import TargetCreate, TargetOut, TargetUpdate
|
from app.schemas.target import (
|
||||||
|
TargetConnectionTestRequest,
|
||||||
|
TargetCreate,
|
||||||
|
TargetOut,
|
||||||
|
TargetOwnerOut,
|
||||||
|
TargetOwnersUpdate,
|
||||||
|
TargetUpdate,
|
||||||
|
)
|
||||||
from app.services.audit import write_audit_log
|
from app.services.audit import write_audit_log
|
||||||
from app.services.collector import build_target_dsn
|
from app.services.collector import build_target_dsn
|
||||||
from app.services.crypto import encrypt_secret
|
from app.services.crypto import encrypt_secret
|
||||||
@@ -17,10 +28,120 @@ from app.services.overview_service import get_target_overview
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _owners_by_target_ids(db: AsyncSession, target_ids: list[int]) -> dict[int, list[int]]:
|
||||||
|
if not target_ids:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
await db.execute(select(TargetOwner.target_id, TargetOwner.user_id).where(TargetOwner.target_id.in_(target_ids)))
|
||||||
|
).all()
|
||||||
|
mapping: dict[int, list[int]] = {target_id: [] for target_id in target_ids}
|
||||||
|
for target_id, user_id in rows:
|
||||||
|
mapping.setdefault(target_id, []).append(user_id)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _target_out_with_owners(target: Target, owner_user_ids: list[int]) -> TargetOut:
|
||||||
|
return TargetOut(
|
||||||
|
id=target.id,
|
||||||
|
name=target.name,
|
||||||
|
host=target.host,
|
||||||
|
port=target.port,
|
||||||
|
dbname=target.dbname,
|
||||||
|
username=target.username,
|
||||||
|
sslmode=target.sslmode,
|
||||||
|
use_pg_stat_statements=target.use_pg_stat_statements,
|
||||||
|
owner_user_ids=owner_user_ids,
|
||||||
|
tags=target.tags or {},
|
||||||
|
created_at=target.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_target_owners(db: AsyncSession, target_id: int, user_ids: list[int], assigned_by_user_id: int | None) -> None:
|
||||||
|
await db.execute(delete(TargetOwner).where(TargetOwner.target_id == target_id))
|
||||||
|
for user_id in sorted(set(user_ids)):
|
||||||
|
db.add(TargetOwner(target_id=target_id, user_id=user_id, assigned_by_user_id=assigned_by_user_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover_databases(payload: TargetCreate) -> list[str]:
|
||||||
|
ssl = False if payload.sslmode == "disable" else True
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
database=payload.dbname,
|
||||||
|
user=payload.username,
|
||||||
|
password=payload.password,
|
||||||
|
ssl=ssl,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT datname
|
||||||
|
FROM pg_database
|
||||||
|
WHERE datallowconn
|
||||||
|
AND NOT datistemplate
|
||||||
|
ORDER BY datname
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [row["datname"] for row in rows if row["datname"]]
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("database_discovery_failed", "Database discovery failed", {"error": str(exc)}),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_unique_target_name(db: AsyncSession, base_name: str) -> str:
|
||||||
|
candidate = base_name.strip()
|
||||||
|
suffix = 2
|
||||||
|
while True:
|
||||||
|
exists = await db.scalar(select(Target.id).where(Target.name == candidate))
|
||||||
|
if exists is None:
|
||||||
|
return candidate
|
||||||
|
candidate = f"{base_name} ({suffix})"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TargetOut])
|
@router.get("", response_model=list[TargetOut])
|
||||||
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
|
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
|
||||||
|
_ = user
|
||||||
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
|
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
|
||||||
return [TargetOut.model_validate(item) for item in targets]
|
owner_map = await _owners_by_target_ids(db, [item.id for item in targets])
|
||||||
|
return [_target_out_with_owners(item, owner_map.get(item.id, [])) for item in targets]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection")
|
||||||
|
async def test_target_connection(
|
||||||
|
payload: TargetConnectionTestRequest,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
) -> dict:
|
||||||
|
_ = user
|
||||||
|
ssl = False if payload.sslmode == "disable" else True
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
database=payload.dbname,
|
||||||
|
user=payload.username,
|
||||||
|
password=payload.password,
|
||||||
|
ssl=ssl,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
version = await conn.fetchval("SHOW server_version")
|
||||||
|
return {"ok": True, "message": "Connection successful", "server_version": version}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("connection_test_failed", "Connection test failed", {"error": str(exc)}),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -29,29 +150,122 @@ async def create_target(
|
|||||||
user: User = Depends(require_roles("admin", "operator")),
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> TargetOut:
|
) -> TargetOut:
|
||||||
|
owner_ids = sorted(set(payload.owner_user_ids or []))
|
||||||
|
if owner_ids:
|
||||||
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
|
||||||
|
if len(set(owners_exist)) != len(owner_ids):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_password = encrypt_secret(payload.password)
|
||||||
|
created_targets: list[Target] = []
|
||||||
|
|
||||||
|
if payload.discover_all_databases:
|
||||||
|
databases = await _discover_databases(payload)
|
||||||
|
if not databases:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("no_databases_discovered", "No databases discovered on target"),
|
||||||
|
)
|
||||||
|
group_id = str(uuid4())
|
||||||
|
base_tags = payload.tags or {}
|
||||||
|
for dbname in databases:
|
||||||
|
duplicate = await db.scalar(
|
||||||
|
select(Target.id).where(
|
||||||
|
Target.host == payload.host,
|
||||||
|
Target.port == payload.port,
|
||||||
|
Target.dbname == dbname,
|
||||||
|
Target.username == payload.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
continue
|
||||||
|
target_name = await _next_unique_target_name(db, f"{payload.name} / {dbname}")
|
||||||
|
tags = {
|
||||||
|
**base_tags,
|
||||||
|
"monitor_mode": "all_databases",
|
||||||
|
"monitor_group_id": group_id,
|
||||||
|
"monitor_group_name": payload.name,
|
||||||
|
}
|
||||||
target = Target(
|
target = Target(
|
||||||
name=payload.name,
|
name=target_name,
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
dbname=dbname,
|
||||||
|
username=payload.username,
|
||||||
|
encrypted_password=encrypted_password,
|
||||||
|
sslmode=payload.sslmode,
|
||||||
|
use_pg_stat_statements=payload.use_pg_stat_statements,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
db.add(target)
|
||||||
|
await db.flush()
|
||||||
|
created_targets.append(target)
|
||||||
|
if owner_ids:
|
||||||
|
await _set_target_owners(db, target.id, owner_ids, user.id)
|
||||||
|
|
||||||
|
if not created_targets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("all_discovered_databases_exist", "All discovered databases already exist as targets"),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
for item in created_targets:
|
||||||
|
await db.refresh(item)
|
||||||
|
await write_audit_log(
|
||||||
|
db,
|
||||||
|
"target.create.all_databases",
|
||||||
|
user.id,
|
||||||
|
{"base_name": payload.name, "created_count": len(created_targets), "host": payload.host, "port": payload.port},
|
||||||
|
)
|
||||||
|
owner_map = await _owners_by_target_ids(db, [created_targets[0].id])
|
||||||
|
return _target_out_with_owners(created_targets[0], owner_map.get(created_targets[0].id, []))
|
||||||
|
|
||||||
|
target_name = await _next_unique_target_name(db, payload.name)
|
||||||
|
target = Target(
|
||||||
|
name=target_name,
|
||||||
host=payload.host,
|
host=payload.host,
|
||||||
port=payload.port,
|
port=payload.port,
|
||||||
dbname=payload.dbname,
|
dbname=payload.dbname,
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
encrypted_password=encrypt_secret(payload.password),
|
encrypted_password=encrypted_password,
|
||||||
sslmode=payload.sslmode,
|
sslmode=payload.sslmode,
|
||||||
|
use_pg_stat_statements=payload.use_pg_stat_statements,
|
||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
)
|
)
|
||||||
db.add(target)
|
db.add(target)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(target)
|
await db.refresh(target)
|
||||||
|
|
||||||
|
if owner_ids:
|
||||||
|
await _set_target_owners(db, target.id, owner_ids, user.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
|
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/owner-candidates", response_model=list[TargetOwnerOut])
|
||||||
|
async def list_owner_candidates(
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[TargetOwnerOut]:
|
||||||
|
_ = user
|
||||||
|
users = (await db.scalars(select(User).order_by(User.email.asc()))).all()
|
||||||
|
return [TargetOwnerOut(user_id=item.id, email=item.email, role=item.role) for item in users]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{target_id}", response_model=TargetOut)
|
@router.get("/{target_id}", response_model=TargetOut)
|
||||||
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
|
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
|
||||||
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{target_id}", response_model=TargetOut)
|
@router.put("/{target_id}", response_model=TargetOut)
|
||||||
@@ -63,17 +277,82 @@ async def update_target(
|
|||||||
) -> TargetOut:
|
) -> TargetOut:
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
owner_user_ids = updates.pop("owner_user_ids", None)
|
||||||
if "password" in updates:
|
if "password" in updates:
|
||||||
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
setattr(target, key, value)
|
setattr(target, key, value)
|
||||||
|
|
||||||
|
if owner_user_ids is not None:
|
||||||
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
||||||
|
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
|
await _set_target_owners(db, target.id, owner_user_ids, user.id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(target)
|
await db.refresh(target)
|
||||||
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
|
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{target_id}/owners", response_model=list[TargetOwnerOut])
|
||||||
|
async def set_target_owners(
|
||||||
|
target_id: int,
|
||||||
|
payload: TargetOwnersUpdate,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[TargetOwnerOut]:
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
owner_user_ids = sorted(set(payload.user_ids))
|
||||||
|
if owner_user_ids:
|
||||||
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
||||||
|
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
|
||||||
|
)
|
||||||
|
await _set_target_owners(db, target_id, owner_user_ids, user.id)
|
||||||
|
await db.commit()
|
||||||
|
await write_audit_log(db, "target.owners.update", user.id, {"target_id": target_id, "owner_user_ids": owner_user_ids})
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(User.id, User.email, User.role)
|
||||||
|
.join(TargetOwner, TargetOwner.user_id == User.id)
|
||||||
|
.where(TargetOwner.target_id == target_id)
|
||||||
|
.order_by(User.email.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{target_id}/owners", response_model=list[TargetOwnerOut])
|
||||||
|
async def get_target_owners(
|
||||||
|
target_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[TargetOwnerOut]:
|
||||||
|
_ = user
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(User.id, User.email, User.role)
|
||||||
|
.join(TargetOwner, TargetOwner.user_id == User.id)
|
||||||
|
.where(TargetOwner.target_id == target_id)
|
||||||
|
.order_by(User.email.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{target_id}")
|
@router.delete("/{target_id}")
|
||||||
@@ -84,7 +363,7 @@ async def delete_target(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
await db.delete(target)
|
await db.delete(target)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await write_audit_log(db, "target.delete", user.id, {"target_id": target_id})
|
await write_audit_log(db, "target.delete", user.id, {"target_id": target_id})
|
||||||
@@ -112,7 +391,22 @@ async def get_metrics(
|
|||||||
|
|
||||||
|
|
||||||
async def _live_conn(target: Target) -> asyncpg.Connection:
|
async def _live_conn(target: Target) -> asyncpg.Connection:
|
||||||
|
try:
|
||||||
return await asyncpg.connect(dsn=build_target_dsn(target))
|
return await asyncpg.connect(dsn=build_target_dsn(target))
|
||||||
|
except (OSError, asyncpg.PostgresError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=api_error(
|
||||||
|
"target_unreachable",
|
||||||
|
"Target database is not reachable",
|
||||||
|
{
|
||||||
|
"target_id": target.id,
|
||||||
|
"host": target.host,
|
||||||
|
"port": target.port,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{target_id}/locks")
|
@router.get("/{target_id}/locks")
|
||||||
@@ -120,7 +414,7 @@ async def get_locks(target_id: int, user: User = Depends(get_current_user), db:
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
conn = await _live_conn(target)
|
conn = await _live_conn(target)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
@@ -141,7 +435,7 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
conn = await _live_conn(target)
|
conn = await _live_conn(target)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
@@ -161,6 +455,11 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
|
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
|
||||||
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
|
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
|
||||||
_ = user
|
_ = user
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
if not target.use_pg_stat_statements:
|
||||||
|
return []
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
select(QueryStat)
|
select(QueryStat)
|
||||||
@@ -188,5 +487,20 @@ async def get_overview(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
_ = user
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||||
|
try:
|
||||||
return await get_target_overview(target)
|
return await get_target_overview(target)
|
||||||
|
except (OSError, asyncpg.PostgresError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=api_error(
|
||||||
|
"target_unreachable",
|
||||||
|
"Target database is not reachable",
|
||||||
|
{
|
||||||
|
"target_id": target.id,
|
||||||
|
"host": target.host,
|
||||||
|
"port": target.port,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
NEXAPG_VERSION = "0.2.4"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
@@ -25,9 +27,17 @@ class Settings(BaseSettings):
|
|||||||
encryption_key: str
|
encryption_key: str
|
||||||
cors_origins: str = "http://localhost:5173"
|
cors_origins: str = "http://localhost:5173"
|
||||||
poll_interval_seconds: int = 30
|
poll_interval_seconds: int = 30
|
||||||
|
alert_active_connection_ratio_min_total_connections: int = 5
|
||||||
|
alert_rollback_ratio_window_minutes: int = 15
|
||||||
|
alert_rollback_ratio_min_total_transactions: int = 100
|
||||||
|
alert_rollback_ratio_min_rollbacks: int = 10
|
||||||
init_admin_email: str = "admin@example.com"
|
init_admin_email: str = "admin@example.com"
|
||||||
init_admin_password: str = "ChangeMe123!"
|
init_admin_password: str = "ChangeMe123!"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_version(self) -> str:
|
||||||
|
return NEXAPG_VERSION
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError, jwt
|
import jwt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.core.errors import api_error
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -16,27 +17,42 @@ async def get_current_user(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("missing_token", "Missing token"),
|
||||||
|
)
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except JWTError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_token", "Invalid token"),
|
||||||
|
) from exc
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("invalid_token_type", "Invalid token type"),
|
||||||
|
)
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=api_error("user_not_found", "User not found"),
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_roles(*roles: str):
|
def require_roles(*roles: str):
|
||||||
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
||||||
if user.role not in roles:
|
if user.role not in roles:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=api_error("forbidden", "Forbidden"),
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
return role_dependency
|
return role_dependency
|
||||||
|
|||||||
38
backend/app/core/errors.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def error_payload(code: str, message: str, details: Any, request_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api_error(code: str, message: str, details: Any = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def http_status_to_code(status_code: int) -> str:
|
||||||
|
mapping = {
|
||||||
|
400: "bad_request",
|
||||||
|
401: "unauthorized",
|
||||||
|
403: "forbidden",
|
||||||
|
404: "not_found",
|
||||||
|
405: "method_not_allowed",
|
||||||
|
409: "conflict",
|
||||||
|
422: "validation_error",
|
||||||
|
429: "rate_limited",
|
||||||
|
500: "internal_error",
|
||||||
|
502: "bad_gateway",
|
||||||
|
503: "service_unavailable",
|
||||||
|
504: "gateway_timeout",
|
||||||
|
}
|
||||||
|
return mapping.get(status_code, f"http_{status_code}")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from jose import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.errors import error_payload, http_status_to_code
|
||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
@@ -57,4 +62,67 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id_middleware(request: Request, call_next):
|
||||||
|
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||||
|
request.state.request_id = request_id
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
code = http_status_to_code(exc.status_code)
|
||||||
|
message = "Request failed"
|
||||||
|
details = None
|
||||||
|
|
||||||
|
if isinstance(exc.detail, str):
|
||||||
|
message = exc.detail
|
||||||
|
elif isinstance(exc.detail, dict):
|
||||||
|
code = str(exc.detail.get("code", code))
|
||||||
|
message = str(exc.detail.get("message", message))
|
||||||
|
details = exc.detail.get("details")
|
||||||
|
elif isinstance(exc.detail, list):
|
||||||
|
message = "Request validation failed"
|
||||||
|
details = exc.detail
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_payload(code=code, message=message, details=details, request_id=request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=error_payload(
|
||||||
|
code="validation_error",
|
||||||
|
message="Request validation failed",
|
||||||
|
details=exc.errors(),
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
logger.exception("unhandled_exception request_id=%s", request_id, exc_info=exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=error_payload(
|
||||||
|
code="internal_error",
|
||||||
|
message="Internal server error",
|
||||||
|
details=None,
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
from app.models.models import AuditLog, Metric, QueryStat, Target, User
|
from app.models.models import (
|
||||||
|
AlertDefinition,
|
||||||
|
AlertNotificationEvent,
|
||||||
|
AuditLog,
|
||||||
|
EmailNotificationSettings,
|
||||||
|
Metric,
|
||||||
|
QueryStat,
|
||||||
|
ServiceInfoSettings,
|
||||||
|
Target,
|
||||||
|
TargetOwner,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog"]
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Target",
|
||||||
|
"Metric",
|
||||||
|
"QueryStat",
|
||||||
|
"ServiceInfoSettings",
|
||||||
|
"AuditLog",
|
||||||
|
"AlertDefinition",
|
||||||
|
"EmailNotificationSettings",
|
||||||
|
"TargetOwner",
|
||||||
|
"AlertNotificationEvent",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.core.db import Base
|
from app.core.db import Base
|
||||||
|
|
||||||
@@ -9,11 +9,18 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
first_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
||||||
|
owned_targets: Mapped[list["TargetOwner"]] = relationship(
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="TargetOwner.user_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Target(Base):
|
class Target(Base):
|
||||||
@@ -27,11 +34,28 @@ class Target(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
|
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
|
||||||
|
use_pg_stat_statements: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
alert_definitions: Mapped[list["AlertDefinition"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
owners: Mapped[list["TargetOwner"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwner(Base):
|
||||||
|
__tablename__ = "target_owners"
|
||||||
|
__table_args__ = (UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
target: Mapped[Target] = relationship(back_populates="owners")
|
||||||
|
user: Mapped[User] = relationship(foreign_keys=[user_id], back_populates="owned_targets")
|
||||||
|
|
||||||
|
|
||||||
class Metric(Base):
|
class Metric(Base):
|
||||||
@@ -73,3 +97,84 @@ class AuditLog(Base):
|
|||||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
user: Mapped[User | None] = relationship(back_populates="audit_logs")
|
user: Mapped[User | None] = relationship(back_populates="audit_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinition(Base):
|
||||||
|
__tablename__ = "alert_definitions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
target_id: Mapped[int | None] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
sql_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
comparison: Mapped[str] = mapped_column(String(10), nullable=False, default="gte")
|
||||||
|
warning_threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
alert_threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettings(Base):
|
||||||
|
__tablename__ = "email_notification_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
|
||||||
|
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
warning_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
alert_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
warning_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
alert_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoSettings(Base):
|
||||||
|
__tablename__ = "service_info_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
current_version: Mapped[str] = mapped_column(String(64), nullable=False, default="0.1.0")
|
||||||
|
release_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
latest_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
update_available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_check_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNotificationEvent(Base):
|
||||||
|
__tablename__ = "alert_notification_events"
|
||||||
|
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
alert_key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||||
|
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
last_sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
56
backend/app/schemas/admin_settings.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
smtp_host: str | None
|
||||||
|
smtp_port: int
|
||||||
|
smtp_username: str | None
|
||||||
|
from_name: str | None
|
||||||
|
from_email: EmailStr | None
|
||||||
|
use_starttls: bool
|
||||||
|
use_ssl: bool
|
||||||
|
warning_subject_template: str | None
|
||||||
|
alert_subject_template: str | None
|
||||||
|
warning_body_template: str | None
|
||||||
|
alert_body_template: str | None
|
||||||
|
has_password: bool
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
clear_smtp_password: bool = False
|
||||||
|
from_name: str | None = None
|
||||||
|
from_email: EmailStr | None = None
|
||||||
|
use_starttls: bool = True
|
||||||
|
use_ssl: bool = False
|
||||||
|
warning_subject_template: str | None = None
|
||||||
|
alert_subject_template: str | None = None
|
||||||
|
warning_body_template: str | None = None
|
||||||
|
alert_body_template: str | None = None
|
||||||
|
|
||||||
|
@field_validator("smtp_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, value: int) -> int:
|
||||||
|
if value < 1 or value > 65535:
|
||||||
|
raise ValueError("smtp_port must be between 1 and 65535")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_tls_combo(self):
|
||||||
|
if self.use_starttls and self.use_ssl:
|
||||||
|
raise ValueError("use_starttls and use_ssl cannot both be true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsTestRequest(BaseModel):
|
||||||
|
recipient: EmailStr
|
||||||
|
subject: str = "NexaPG test notification"
|
||||||
|
message: str = "This is a test alert notification from NexaPG."
|
||||||
83
backend/app/schemas/alert.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionBase(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
target_id: int | None = None
|
||||||
|
sql_text: str = Field(min_length=8, max_length=4000)
|
||||||
|
comparison: str = "gte"
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionCreate(AlertDefinitionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=2, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
target_id: int | None = None
|
||||||
|
sql_text: str | None = Field(default=None, min_length=8, max_length=4000)
|
||||||
|
comparison: str | None = None
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionOut(AlertDefinitionBase):
|
||||||
|
id: int
|
||||||
|
created_by_user_id: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionTestRequest(BaseModel):
|
||||||
|
target_id: int
|
||||||
|
sql_text: str = Field(min_length=8, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionTestResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
value: float | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatusItem(BaseModel):
|
||||||
|
alert_key: str
|
||||||
|
source: str
|
||||||
|
severity: str
|
||||||
|
category: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
target_id: int
|
||||||
|
target_name: str
|
||||||
|
value: float | None = None
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float | None = None
|
||||||
|
comparison: str = "gte"
|
||||||
|
message: str
|
||||||
|
checked_at: datetime
|
||||||
|
sql_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatusResponse(BaseModel):
|
||||||
|
generated_at: datetime
|
||||||
|
warnings: list[AlertStatusItem]
|
||||||
|
alerts: list[AlertStatusItem]
|
||||||
|
warning_count: int
|
||||||
|
alert_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAlertReferenceItem(BaseModel):
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
checks: str
|
||||||
|
comparison: str
|
||||||
|
warning: str
|
||||||
|
alert: str
|
||||||
29
backend/app/schemas/service_info.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoOut(BaseModel):
|
||||||
|
app_name: str
|
||||||
|
environment: str
|
||||||
|
api_prefix: str
|
||||||
|
app_version: str
|
||||||
|
hostname: str
|
||||||
|
python_version: str
|
||||||
|
platform: str
|
||||||
|
service_started_at: datetime
|
||||||
|
uptime_seconds: int
|
||||||
|
update_source: str
|
||||||
|
latest_version: str | None
|
||||||
|
latest_ref: str | None
|
||||||
|
update_available: bool
|
||||||
|
last_checked_at: datetime | None
|
||||||
|
last_check_error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoCheckResult(BaseModel):
|
||||||
|
latest_version: str | None
|
||||||
|
latest_ref: str | None
|
||||||
|
update_available: bool
|
||||||
|
last_checked_at: datetime
|
||||||
|
last_check_error: str | None
|
||||||
@@ -9,11 +9,23 @@ class TargetBase(BaseModel):
|
|||||||
dbname: str
|
dbname: str
|
||||||
username: str
|
username: str
|
||||||
sslmode: str = "prefer"
|
sslmode: str = "prefer"
|
||||||
|
use_pg_stat_statements: bool = True
|
||||||
|
owner_user_ids: list[int] = Field(default_factory=list)
|
||||||
tags: dict = Field(default_factory=dict)
|
tags: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class TargetCreate(TargetBase):
|
class TargetCreate(TargetBase):
|
||||||
password: str
|
password: str
|
||||||
|
discover_all_databases: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TargetConnectionTestRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = 5432
|
||||||
|
dbname: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
sslmode: str = "prefer"
|
||||||
|
|
||||||
|
|
||||||
class TargetUpdate(BaseModel):
|
class TargetUpdate(BaseModel):
|
||||||
@@ -24,6 +36,8 @@ class TargetUpdate(BaseModel):
|
|||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
sslmode: str | None = None
|
sslmode: str | None = None
|
||||||
|
use_pg_stat_statements: bool | None = None
|
||||||
|
owner_user_ids: list[int] | None = None
|
||||||
tags: dict | None = None
|
tags: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -32,3 +46,13 @@ class TargetOut(TargetBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwnerOut(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwnersUpdate(BaseModel):
|
||||||
|
user_ids: list[int] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
role: str
|
role: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -13,11 +15,27 @@ class UserOut(BaseModel):
|
|||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
password: str
|
password: str
|
||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_new_password(cls, value: str) -> str:
|
||||||
|
if len(value) < 8:
|
||||||
|
raise ValueError("new_password must be at least 8 characters")
|
||||||
|
return value
|
||||||
|
|||||||
196
backend/app/services/alert_notifications.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.models import AlertNotificationEvent, EmailNotificationSettings, TargetOwner, User
|
||||||
|
from app.schemas.alert import AlertStatusResponse
|
||||||
|
from app.services.crypto import decrypt_secret
|
||||||
|
|
||||||
|
_NOTIFICATION_COOLDOWN = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
async def _smtp_send(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
from_name: str | None,
|
||||||
|
from_email: str,
|
||||||
|
recipient: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
use_starttls: bool,
|
||||||
|
use_ssl: bool,
|
||||||
|
) -> None:
|
||||||
|
def _send() -> None:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = formataddr((from_name, from_email)) if from_name else from_email
|
||||||
|
message["To"] = recipient
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
if use_ssl:
|
||||||
|
with smtplib.SMTP_SSL(host, port, timeout=10, context=ssl.create_default_context()) as smtp:
|
||||||
|
if username:
|
||||||
|
smtp.login(username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
return
|
||||||
|
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||||
|
if use_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
if username:
|
||||||
|
smtp.login(username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_send)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_subject(item) -> str:
|
||||||
|
sev = item.severity.upper()
|
||||||
|
return f"[NexaPG][{sev}] {item.target_name} - {item.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_body(item) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Severity: {item.severity}",
|
||||||
|
f"Target: {item.target_name} (id={item.target_id})",
|
||||||
|
f"Alert: {item.name}",
|
||||||
|
f"Category: {item.category}",
|
||||||
|
f"Checked At: {item.checked_at.isoformat()}",
|
||||||
|
"",
|
||||||
|
f"Description: {item.description}",
|
||||||
|
f"Message: {item.message}",
|
||||||
|
]
|
||||||
|
if item.value is not None:
|
||||||
|
lines.append(f"Current Value: {item.value}")
|
||||||
|
if item.warning_threshold is not None:
|
||||||
|
lines.append(f"Warning Threshold: {item.warning_threshold}")
|
||||||
|
if item.alert_threshold is not None:
|
||||||
|
lines.append(f"Alert Threshold: {item.alert_threshold}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Alert Key: {item.alert_key}")
|
||||||
|
lines.append("Sent by NexaPG notification service.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_context(item) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"severity": str(item.severity),
|
||||||
|
"target_name": str(item.target_name),
|
||||||
|
"target_id": str(item.target_id),
|
||||||
|
"alert_name": str(item.name),
|
||||||
|
"category": str(item.category),
|
||||||
|
"description": str(item.description),
|
||||||
|
"message": str(item.message),
|
||||||
|
"value": "" if item.value is None else str(item.value),
|
||||||
|
"warning_threshold": "" if item.warning_threshold is None else str(item.warning_threshold),
|
||||||
|
"alert_threshold": "" if item.alert_threshold is None else str(item.alert_threshold),
|
||||||
|
"checked_at": item.checked_at.isoformat() if item.checked_at else "",
|
||||||
|
"alert_key": str(item.alert_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_format(template: str | None, context: dict[str, str], fallback: str) -> str:
|
||||||
|
if not template:
|
||||||
|
return fallback
|
||||||
|
rendered = template
|
||||||
|
for key, value in context.items():
|
||||||
|
rendered = rendered.replace("{" + key + "}", value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
async def process_target_owner_notifications(db: AsyncSession, status: AlertStatusResponse) -> None:
|
||||||
|
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
|
||||||
|
if not settings or not settings.enabled:
|
||||||
|
return
|
||||||
|
if not settings.smtp_host or not settings.from_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
active_items = status.alerts + status.warnings
|
||||||
|
if not active_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_ids = sorted({item.target_id for item in active_items})
|
||||||
|
owner_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(TargetOwner.target_id, User.email)
|
||||||
|
.join(User, User.id == TargetOwner.user_id)
|
||||||
|
.where(TargetOwner.target_id.in_(target_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
owners_map: dict[int, set[str]] = {}
|
||||||
|
for target_id, email in owner_rows:
|
||||||
|
owners_map.setdefault(target_id, set()).add(email)
|
||||||
|
|
||||||
|
existing_rows = (
|
||||||
|
await db.scalars(
|
||||||
|
select(AlertNotificationEvent).where(
|
||||||
|
AlertNotificationEvent.target_id.in_(target_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
event_map = {(row.alert_key, row.target_id, row.severity): row for row in existing_rows}
|
||||||
|
|
||||||
|
for item in active_items:
|
||||||
|
recipients = sorted(owners_map.get(item.target_id, set()))
|
||||||
|
if not recipients:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (item.alert_key, item.target_id, item.severity)
|
||||||
|
existing = event_map.get(key)
|
||||||
|
should_send = existing is None or (now - existing.last_sent_at) >= _NOTIFICATION_COOLDOWN
|
||||||
|
|
||||||
|
if should_send:
|
||||||
|
fallback_subject = _render_subject(item)
|
||||||
|
fallback_body = _render_body(item)
|
||||||
|
context = _template_context(item)
|
||||||
|
if item.severity == "alert":
|
||||||
|
subject = _safe_format(settings.alert_subject_template, context, fallback_subject)
|
||||||
|
body = _safe_format(settings.alert_body_template, context, fallback_body)
|
||||||
|
else:
|
||||||
|
subject = _safe_format(settings.warning_subject_template, context, fallback_subject)
|
||||||
|
body = _safe_format(settings.warning_body_template, context, fallback_body)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
await _smtp_send(
|
||||||
|
host=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username,
|
||||||
|
password=password,
|
||||||
|
from_name=settings.from_name,
|
||||||
|
from_email=settings.from_email,
|
||||||
|
recipient=recipient,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
use_starttls=settings.use_starttls,
|
||||||
|
use_ssl=settings.use_ssl,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.last_seen_at = now
|
||||||
|
if should_send:
|
||||||
|
existing.last_sent_at = now
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
AlertNotificationEvent(
|
||||||
|
alert_key=item.alert_key,
|
||||||
|
target_id=item.target_id,
|
||||||
|
severity=item.severity,
|
||||||
|
last_seen_at=now,
|
||||||
|
last_sent_at=now if should_send else now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
625
backend/app/services/alerts.py
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import api_error
|
||||||
|
from app.models.models import AlertDefinition, Metric, QueryStat, Target
|
||||||
|
from app.schemas.alert import AlertStatusItem, AlertStatusResponse
|
||||||
|
from app.services.collector import build_target_dsn
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_ALLOWED_COMPARISONS = {"gte", "gt", "lte", "lt"}
|
||||||
|
_FORBIDDEN_SQL_WORDS = re.compile(
|
||||||
|
r"\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|vacuum|analyze|copy|call|do)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_STATUS_CACHE_TTL_SECONDS = 15
|
||||||
|
_status_cache: dict = {"expires": 0.0, "data": None}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _RuleInput:
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
value: float | None
|
||||||
|
warning_threshold: float | None
|
||||||
|
alert_threshold: float | None
|
||||||
|
comparison: str = "gte"
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_alert_cache() -> None:
|
||||||
|
_status_cache["expires"] = 0.0
|
||||||
|
_status_cache["data"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_standard_alert_reference() -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "target_reachability",
|
||||||
|
"name": "Target Reachability",
|
||||||
|
"checks": "Connection to target database can be established.",
|
||||||
|
"comparison": "-",
|
||||||
|
"warning": "-",
|
||||||
|
"alert": "On connection failure",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connectivity_rtt_ms",
|
||||||
|
"name": "Connectivity Latency",
|
||||||
|
"checks": "Connection handshake duration (milliseconds).",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "1000 ms",
|
||||||
|
"alert": "2500 ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "collector_freshness_seconds",
|
||||||
|
"name": "Collector Freshness",
|
||||||
|
"checks": "Age of newest metric sample.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": f"{settings.poll_interval_seconds * 2} s (poll interval x2)",
|
||||||
|
"alert": f"{settings.poll_interval_seconds * 4} s (poll interval x4)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "active_connections_ratio",
|
||||||
|
"name": "Active Connection Ratio",
|
||||||
|
"checks": (
|
||||||
|
"active_connections / total_connections "
|
||||||
|
f"(evaluated only when total sessions >= {settings.alert_active_connection_ratio_min_total_connections})."
|
||||||
|
),
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "0.70",
|
||||||
|
"alert": "0.90",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cache_hit_ratio_low",
|
||||||
|
"name": "Cache Hit Ratio",
|
||||||
|
"checks": "Buffer cache efficiency (lower is worse).",
|
||||||
|
"comparison": "lte",
|
||||||
|
"warning": "0.95",
|
||||||
|
"alert": "0.90",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "locks_total",
|
||||||
|
"name": "Lock Pressure",
|
||||||
|
"checks": "Current total lock count.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "50",
|
||||||
|
"alert": "100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "checkpoints_req_15m",
|
||||||
|
"name": "Checkpoint Pressure (15m)",
|
||||||
|
"checks": "Increase of requested checkpoints in last 15 minutes.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "5",
|
||||||
|
"alert": "15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rollback_ratio",
|
||||||
|
"name": "Rollback Ratio",
|
||||||
|
"checks": (
|
||||||
|
f"rollback / (commit + rollback) in last {settings.alert_rollback_ratio_window_minutes} minutes "
|
||||||
|
f"(evaluated only when >= {settings.alert_rollback_ratio_min_total_transactions} transactions "
|
||||||
|
f"and >= {settings.alert_rollback_ratio_min_rollbacks} rollbacks)."
|
||||||
|
),
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "0.10",
|
||||||
|
"alert": "0.25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "deadlocks_60m",
|
||||||
|
"name": "Deadlocks (60m)",
|
||||||
|
"checks": "Increase in deadlocks during last 60 minutes.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "1",
|
||||||
|
"alert": "5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "slowest_query_mean_ms",
|
||||||
|
"name": "Slowest Query Mean Time",
|
||||||
|
"checks": "Highest query mean execution time in latest snapshot.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "300 ms",
|
||||||
|
"alert": "1000 ms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "slowest_query_total_ms",
|
||||||
|
"name": "Slowest Query Total Time",
|
||||||
|
"checks": "Highest query total execution time in latest snapshot.",
|
||||||
|
"comparison": "gte",
|
||||||
|
"warning": "3000 ms",
|
||||||
|
"alert": "10000 ms",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_alert_thresholds(comparison: str, warning_threshold: float | None, alert_threshold: float) -> None:
|
||||||
|
if comparison not in _ALLOWED_COMPARISONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error(
|
||||||
|
"invalid_comparison",
|
||||||
|
f"Invalid comparison. Use one of {sorted(_ALLOWED_COMPARISONS)}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if warning_threshold is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if comparison in {"gte", "gt"} and warning_threshold > alert_threshold:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_thresholds", "For gte/gt, warning_threshold must be <= alert_threshold"),
|
||||||
|
)
|
||||||
|
if comparison in {"lte", "lt"} and warning_threshold < alert_threshold:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_thresholds", "For lte/lt, warning_threshold must be >= alert_threshold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_alert_sql(sql_text: str) -> str:
|
||||||
|
sql = sql_text.strip().rstrip(";")
|
||||||
|
lowered = sql.lower().strip()
|
||||||
|
if not lowered.startswith("select"):
|
||||||
|
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Alert SQL must start with SELECT"))
|
||||||
|
if _FORBIDDEN_SQL_WORDS.search(lowered):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=api_error("invalid_alert_sql", "Only read-only SELECT statements are allowed"),
|
||||||
|
)
|
||||||
|
if ";" in sql:
|
||||||
|
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Only a single SQL statement is allowed"))
|
||||||
|
return sql
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_target(target: Target, timeout_seconds: int = 5) -> asyncpg.Connection:
|
||||||
|
return await asyncpg.connect(dsn=build_target_dsn(target), timeout=timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_scalar_sql_for_target(target: Target, sql_text: str) -> float:
|
||||||
|
sql = validate_alert_sql(sql_text)
|
||||||
|
conn: asyncpg.Connection | None = None
|
||||||
|
try:
|
||||||
|
conn = await _connect_target(target, timeout_seconds=6)
|
||||||
|
row = await conn.fetchrow(sql)
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Query returned no rows")
|
||||||
|
value = row[0]
|
||||||
|
return float(value)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _compare(value: float, threshold: float, comparison: str) -> bool:
|
||||||
|
if comparison == "gte":
|
||||||
|
return value >= threshold
|
||||||
|
if comparison == "gt":
|
||||||
|
return value > threshold
|
||||||
|
if comparison == "lte":
|
||||||
|
return value <= threshold
|
||||||
|
if comparison == "lt":
|
||||||
|
return value < threshold
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_from_thresholds(
|
||||||
|
value: float | None, comparison: str, warning_threshold: float | None, alert_threshold: float | None
|
||||||
|
) -> str:
|
||||||
|
if value is None or alert_threshold is None:
|
||||||
|
return "unknown"
|
||||||
|
if _compare(value, alert_threshold, comparison):
|
||||||
|
return "alert"
|
||||||
|
if warning_threshold is not None and _compare(value, warning_threshold, comparison):
|
||||||
|
return "warning"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_message(value: float | None, comparison: str, warning_threshold: float | None, alert_threshold: float | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "No numeric value available"
|
||||||
|
if alert_threshold is None:
|
||||||
|
return f"Current value: {value:.2f}"
|
||||||
|
if warning_threshold is None:
|
||||||
|
return f"Current value: {value:.2f} (alert when value {comparison} {alert_threshold:.2f})"
|
||||||
|
return (
|
||||||
|
f"Current value: {value:.2f} "
|
||||||
|
f"(warning when value {comparison} {warning_threshold:.2f}, alert when value {comparison} {alert_threshold:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_metric_value(db: AsyncSession, target_id: int, metric_name: str) -> float | None:
|
||||||
|
row = await db.scalar(
|
||||||
|
select(Metric.value)
|
||||||
|
.where(Metric.target_id == target_id, Metric.metric_name == metric_name)
|
||||||
|
.order_by(desc(Metric.ts))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return float(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _metric_delta(db: AsyncSession, target_id: int, metric_name: str, minutes: int) -> float | None:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||||
|
latest = await db.scalar(
|
||||||
|
select(Metric.value)
|
||||||
|
.where(Metric.target_id == target_id, Metric.metric_name == metric_name)
|
||||||
|
.order_by(desc(Metric.ts))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
oldest = await db.scalar(
|
||||||
|
select(Metric.value)
|
||||||
|
.where(Metric.target_id == target_id, Metric.metric_name == metric_name, Metric.ts >= cutoff)
|
||||||
|
.order_by(Metric.ts.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if latest is None or oldest is None:
|
||||||
|
return None
|
||||||
|
return max(0.0, float(latest) - float(oldest))
|
||||||
|
|
||||||
|
|
||||||
|
async def _rollback_ratio_recent(
|
||||||
|
db: AsyncSession, target_id: int, minutes: int, min_total_transactions: int, min_rollbacks: int
|
||||||
|
) -> tuple[float | None, float, float]:
|
||||||
|
commit_delta = await _metric_delta(db, target_id, "xact_commit", minutes=minutes)
|
||||||
|
rollback_delta = await _metric_delta(db, target_id, "xact_rollback", minutes=minutes)
|
||||||
|
if commit_delta is None or rollback_delta is None:
|
||||||
|
return None, 0.0, 0.0
|
||||||
|
tx_total = commit_delta + rollback_delta
|
||||||
|
if tx_total < float(min_total_transactions):
|
||||||
|
# Too little traffic in window, ratio would be noisy and misleading.
|
||||||
|
return None, tx_total, rollback_delta
|
||||||
|
if rollback_delta < float(min_rollbacks):
|
||||||
|
# Ignore tiny rollback counts even if ratio appears high on low absolute numbers.
|
||||||
|
return None, tx_total, rollback_delta
|
||||||
|
return (rollback_delta / tx_total) if tx_total > 0 else None, tx_total, rollback_delta
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_query_snapshot_max(db: AsyncSession, target_id: int, column_name: str) -> float | None:
|
||||||
|
latest_ts = await db.scalar(select(func.max(QueryStat.ts)).where(QueryStat.target_id == target_id))
|
||||||
|
if latest_ts is None:
|
||||||
|
return None
|
||||||
|
column = QueryStat.mean_time if column_name == "mean_time" else QueryStat.total_time
|
||||||
|
value = await db.scalar(
|
||||||
|
select(func.max(column)).where(QueryStat.target_id == target_id, QueryStat.ts == latest_ts)
|
||||||
|
)
|
||||||
|
return float(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_standard_rules(db: AsyncSession, target: Target) -> tuple[list[_RuleInput], list[AlertStatusItem]]:
|
||||||
|
rules: list[_RuleInput] = []
|
||||||
|
forced_items: list[AlertStatusItem] = []
|
||||||
|
checked_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 1) Connectivity with RTT threshold.
|
||||||
|
start = time.perf_counter()
|
||||||
|
conn: asyncpg.Connection | None = None
|
||||||
|
try:
|
||||||
|
conn = await _connect_target(target, timeout_seconds=4)
|
||||||
|
await conn.fetchval("SELECT 1")
|
||||||
|
connect_ms = (time.perf_counter() - start) * 1000
|
||||||
|
rules.append(
|
||||||
|
_RuleInput(
|
||||||
|
key="connectivity_rtt_ms",
|
||||||
|
name="Connectivity Latency",
|
||||||
|
description="Checks whether the target is reachable and how long the connection handshake takes.",
|
||||||
|
category="availability",
|
||||||
|
value=connect_ms,
|
||||||
|
warning_threshold=1000,
|
||||||
|
alert_threshold=2500,
|
||||||
|
comparison="gte",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
forced_items.append(
|
||||||
|
AlertStatusItem(
|
||||||
|
alert_key=f"std-connectivity-down-{target.id}",
|
||||||
|
source="standard",
|
||||||
|
severity="alert",
|
||||||
|
category="availability",
|
||||||
|
name="Target Reachability",
|
||||||
|
description="Verifies that the monitored database can be reached by the collector.",
|
||||||
|
target_id=target.id,
|
||||||
|
target_name=target.name,
|
||||||
|
value=None,
|
||||||
|
warning_threshold=None,
|
||||||
|
alert_threshold=None,
|
||||||
|
comparison="gte",
|
||||||
|
message=f"Connection failed: {exc}",
|
||||||
|
checked_at=checked_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
# 2) Collector freshness based on latest stored metric.
|
||||||
|
latest_ts = await db.scalar(select(func.max(Metric.ts)).where(Metric.target_id == target.id))
|
||||||
|
if latest_ts is None:
|
||||||
|
forced_items.append(
|
||||||
|
AlertStatusItem(
|
||||||
|
alert_key=f"std-metric-freshness-{target.id}",
|
||||||
|
source="standard",
|
||||||
|
severity="warning",
|
||||||
|
category="availability",
|
||||||
|
name="Collector Freshness",
|
||||||
|
description="Ensures fresh metrics are arriving for the target.",
|
||||||
|
target_id=target.id,
|
||||||
|
target_name=target.name,
|
||||||
|
value=None,
|
||||||
|
warning_threshold=None,
|
||||||
|
alert_threshold=None,
|
||||||
|
comparison="gte",
|
||||||
|
message="No metrics collected yet for this target.",
|
||||||
|
checked_at=checked_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
age_seconds = max(0.0, (checked_at - latest_ts).total_seconds())
|
||||||
|
rules.append(
|
||||||
|
_RuleInput(
|
||||||
|
key="collector_freshness_seconds",
|
||||||
|
name="Collector Freshness",
|
||||||
|
description="Age of the most recent metric sample.",
|
||||||
|
category="availability",
|
||||||
|
value=age_seconds,
|
||||||
|
warning_threshold=float(settings.poll_interval_seconds * 2),
|
||||||
|
alert_threshold=float(settings.poll_interval_seconds * 4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
active_connections = await _latest_metric_value(db, target.id, "connections_active")
|
||||||
|
total_connections = await _latest_metric_value(db, target.id, "connections_total")
|
||||||
|
cache_hit_ratio = await _latest_metric_value(db, target.id, "cache_hit_ratio")
|
||||||
|
lock_count = await _latest_metric_value(db, target.id, "locks_total")
|
||||||
|
checkpoints_req_delta = await _metric_delta(db, target.id, "checkpoints_req", minutes=15)
|
||||||
|
deadlocks_delta = await _metric_delta(db, target.id, "deadlocks", minutes=60)
|
||||||
|
slowest_query_mean = await _latest_query_snapshot_max(db, target.id, "mean_time")
|
||||||
|
slowest_query_total = await _latest_query_snapshot_max(db, target.id, "total_time")
|
||||||
|
|
||||||
|
active_ratio = None
|
||||||
|
if (
|
||||||
|
active_connections is not None
|
||||||
|
and total_connections is not None
|
||||||
|
and total_connections >= settings.alert_active_connection_ratio_min_total_connections
|
||||||
|
):
|
||||||
|
active_ratio = active_connections / total_connections
|
||||||
|
|
||||||
|
rollback_ratio_window = settings.alert_rollback_ratio_window_minutes
|
||||||
|
rollback_ratio_val, tx_total_window, rollback_count_window = await _rollback_ratio_recent(
|
||||||
|
db,
|
||||||
|
target.id,
|
||||||
|
minutes=rollback_ratio_window,
|
||||||
|
min_total_transactions=settings.alert_rollback_ratio_min_total_transactions,
|
||||||
|
min_rollbacks=settings.alert_rollback_ratio_min_rollbacks,
|
||||||
|
)
|
||||||
|
|
||||||
|
rules.extend(
|
||||||
|
[
|
||||||
|
_RuleInput(
|
||||||
|
key="active_connections_ratio",
|
||||||
|
name="Active Connection Ratio",
|
||||||
|
description=(
|
||||||
|
"Share of active sessions over total sessions. "
|
||||||
|
f"Only evaluated when total sessions >= {settings.alert_active_connection_ratio_min_total_connections}."
|
||||||
|
),
|
||||||
|
category="capacity",
|
||||||
|
value=active_ratio,
|
||||||
|
warning_threshold=0.70,
|
||||||
|
alert_threshold=0.90,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="cache_hit_ratio_low",
|
||||||
|
name="Cache Hit Ratio",
|
||||||
|
description="Low cache hit ratio means increased disk reads and slower queries.",
|
||||||
|
category="performance",
|
||||||
|
value=cache_hit_ratio,
|
||||||
|
warning_threshold=0.95,
|
||||||
|
alert_threshold=0.90,
|
||||||
|
comparison="lte",
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="locks_total",
|
||||||
|
name="Lock Pressure",
|
||||||
|
description="Number of locks currently held on the target.",
|
||||||
|
category="contention",
|
||||||
|
value=lock_count,
|
||||||
|
warning_threshold=50,
|
||||||
|
alert_threshold=100,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="checkpoints_req_15m",
|
||||||
|
name="Checkpoint Pressure (15m)",
|
||||||
|
description="Increase of requested checkpoints in the last 15 minutes.",
|
||||||
|
category="io",
|
||||||
|
value=checkpoints_req_delta,
|
||||||
|
warning_threshold=5,
|
||||||
|
alert_threshold=15,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="rollback_ratio",
|
||||||
|
name="Rollback Ratio",
|
||||||
|
description=(
|
||||||
|
f"Fraction of rolled back transactions in the last {rollback_ratio_window} minutes "
|
||||||
|
f"(evaluated only when at least {settings.alert_rollback_ratio_min_total_transactions} "
|
||||||
|
f"transactions and {settings.alert_rollback_ratio_min_rollbacks} rollbacks occurred)."
|
||||||
|
),
|
||||||
|
category="transactions",
|
||||||
|
value=rollback_ratio_val,
|
||||||
|
warning_threshold=0.10,
|
||||||
|
alert_threshold=0.25,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="deadlocks_60m",
|
||||||
|
name="Deadlocks (60m)",
|
||||||
|
description="Increase in deadlocks during the last hour.",
|
||||||
|
category="contention",
|
||||||
|
value=deadlocks_delta,
|
||||||
|
warning_threshold=1,
|
||||||
|
alert_threshold=5,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="slowest_query_mean_ms",
|
||||||
|
name="Slowest Query Mean Time",
|
||||||
|
description="Highest mean execution time in the latest query snapshot.",
|
||||||
|
category="query",
|
||||||
|
value=slowest_query_mean,
|
||||||
|
warning_threshold=300,
|
||||||
|
alert_threshold=1000,
|
||||||
|
),
|
||||||
|
_RuleInput(
|
||||||
|
key="slowest_query_total_ms",
|
||||||
|
name="Slowest Query Total Time",
|
||||||
|
description="Highest total execution time in the latest query snapshot.",
|
||||||
|
category="query",
|
||||||
|
value=slowest_query_total,
|
||||||
|
warning_threshold=3000,
|
||||||
|
alert_threshold=10000,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Expose transaction volume as contextual metric for UI/debugging.
|
||||||
|
rules.append(
|
||||||
|
_RuleInput(
|
||||||
|
key="rollback_tx_volume_15m",
|
||||||
|
name="Rollback Ratio Evaluation Volume",
|
||||||
|
description=f"Total transactions in the last {rollback_ratio_window} minutes used for rollback-ratio evaluation.",
|
||||||
|
category="transactions",
|
||||||
|
value=tx_total_window,
|
||||||
|
warning_threshold=None,
|
||||||
|
alert_threshold=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rules.append(
|
||||||
|
_RuleInput(
|
||||||
|
key="rollback_count_window",
|
||||||
|
name="Rollback Count (Window)",
|
||||||
|
description=f"Rollback count in the last {rollback_ratio_window} minutes used for rollback-ratio evaluation.",
|
||||||
|
category="transactions",
|
||||||
|
value=rollback_count_window,
|
||||||
|
warning_threshold=None,
|
||||||
|
alert_threshold=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rules, forced_items
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_standard_alerts(db: AsyncSession, targets: list[Target]) -> list[AlertStatusItem]:
|
||||||
|
checked_at = datetime.now(timezone.utc)
|
||||||
|
items: list[AlertStatusItem] = []
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
rules, forced_items = await _build_standard_rules(db, target)
|
||||||
|
items.extend(forced_items)
|
||||||
|
for rule in rules:
|
||||||
|
severity = _severity_from_thresholds(rule.value, rule.comparison, rule.warning_threshold, rule.alert_threshold)
|
||||||
|
if severity not in {"warning", "alert"}:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
AlertStatusItem(
|
||||||
|
alert_key=f"std-{rule.key}-{target.id}",
|
||||||
|
source="standard",
|
||||||
|
severity=severity,
|
||||||
|
category=rule.category,
|
||||||
|
name=rule.name,
|
||||||
|
description=rule.description,
|
||||||
|
target_id=target.id,
|
||||||
|
target_name=target.name,
|
||||||
|
value=rule.value,
|
||||||
|
warning_threshold=rule.warning_threshold,
|
||||||
|
alert_threshold=rule.alert_threshold,
|
||||||
|
comparison=rule.comparison,
|
||||||
|
message=_status_message(rule.value, rule.comparison, rule.warning_threshold, rule.alert_threshold),
|
||||||
|
checked_at=checked_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def _evaluate_custom_alerts(db: AsyncSession, targets: list[Target]) -> list[AlertStatusItem]:
|
||||||
|
checked_at = datetime.now(timezone.utc)
|
||||||
|
defs = (
|
||||||
|
await db.scalars(select(AlertDefinition).where(AlertDefinition.enabled.is_(True)).order_by(desc(AlertDefinition.id)))
|
||||||
|
).all()
|
||||||
|
target_by_id = {t.id: t for t in targets}
|
||||||
|
items: list[AlertStatusItem] = []
|
||||||
|
|
||||||
|
for definition in defs:
|
||||||
|
target_candidates = targets if definition.target_id is None else [target_by_id.get(definition.target_id)]
|
||||||
|
for target in [t for t in target_candidates if t is not None]:
|
||||||
|
value: float | None = None
|
||||||
|
severity = "unknown"
|
||||||
|
message = "No data"
|
||||||
|
try:
|
||||||
|
value = await run_scalar_sql_for_target(target, definition.sql_text)
|
||||||
|
severity = _severity_from_thresholds(
|
||||||
|
value=value,
|
||||||
|
comparison=definition.comparison,
|
||||||
|
warning_threshold=definition.warning_threshold,
|
||||||
|
alert_threshold=definition.alert_threshold,
|
||||||
|
)
|
||||||
|
message = _status_message(value, definition.comparison, definition.warning_threshold, definition.alert_threshold)
|
||||||
|
except Exception as exc:
|
||||||
|
severity = "alert"
|
||||||
|
message = f"Execution failed: {exc}"
|
||||||
|
|
||||||
|
if severity not in {"warning", "alert"}:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
AlertStatusItem(
|
||||||
|
alert_key=f"custom-{definition.id}-{target.id}",
|
||||||
|
source="custom",
|
||||||
|
severity=severity,
|
||||||
|
category="custom",
|
||||||
|
name=definition.name,
|
||||||
|
description=definition.description or "Custom SQL alert",
|
||||||
|
target_id=target.id,
|
||||||
|
target_name=target.name,
|
||||||
|
value=value,
|
||||||
|
warning_threshold=definition.warning_threshold,
|
||||||
|
alert_threshold=definition.alert_threshold,
|
||||||
|
comparison=definition.comparison,
|
||||||
|
message=message,
|
||||||
|
checked_at=checked_at,
|
||||||
|
sql_text=definition.sql_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alert_status(db: AsyncSession, use_cache: bool = True) -> AlertStatusResponse:
|
||||||
|
now_seconds = time.time()
|
||||||
|
cached = _status_cache.get("data")
|
||||||
|
expires = float(_status_cache.get("expires", 0.0))
|
||||||
|
if use_cache and cached and expires > now_seconds:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
targets = (await db.scalars(select(Target).order_by(Target.name.asc()))).all()
|
||||||
|
standard_items = await _evaluate_standard_alerts(db, targets)
|
||||||
|
custom_items = await _evaluate_custom_alerts(db, targets)
|
||||||
|
all_items = standard_items + custom_items
|
||||||
|
|
||||||
|
warnings = [item for item in all_items if item.severity == "warning"]
|
||||||
|
alerts = [item for item in all_items if item.severity == "alert"]
|
||||||
|
warnings.sort(key=lambda i: (i.target_name.lower(), i.name.lower()))
|
||||||
|
alerts.sort(key=lambda i: (i.target_name.lower(), i.name.lower()))
|
||||||
|
|
||||||
|
payload = AlertStatusResponse(
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
warnings=warnings,
|
||||||
|
alerts=alerts,
|
||||||
|
warning_count=len(warnings),
|
||||||
|
alert_count=len(alerts),
|
||||||
|
)
|
||||||
|
_status_cache["data"] = payload
|
||||||
|
_status_cache["expires"] = now_seconds + _STATUS_CACHE_TTL_SECONDS
|
||||||
|
return payload
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from random import uniform
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
@@ -13,6 +14,11 @@ import asyncpg
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
_failure_state: dict[int, dict[str, object]] = {}
|
||||||
|
_failure_log_interval_seconds = 300
|
||||||
|
_backoff_base_seconds = max(3, int(settings.poll_interval_seconds))
|
||||||
|
_backoff_max_seconds = 300
|
||||||
|
_backoff_jitter_factor = 0.15
|
||||||
|
|
||||||
|
|
||||||
def build_target_dsn(target: Target) -> str:
|
def build_target_dsn(target: Target) -> str:
|
||||||
@@ -41,7 +47,19 @@ async def collect_target(target: Target) -> None:
|
|||||||
try:
|
try:
|
||||||
stat_db = await conn.fetchrow(
|
stat_db = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT numbackends, xact_commit, xact_rollback, blks_hit, blks_read, tup_returned, tup_fetched
|
SELECT
|
||||||
|
numbackends,
|
||||||
|
xact_commit,
|
||||||
|
xact_rollback,
|
||||||
|
deadlocks,
|
||||||
|
temp_files,
|
||||||
|
temp_bytes,
|
||||||
|
blk_read_time,
|
||||||
|
blk_write_time,
|
||||||
|
blks_hit,
|
||||||
|
blks_read,
|
||||||
|
tup_returned,
|
||||||
|
tup_fetched
|
||||||
FROM pg_stat_database
|
FROM pg_stat_database
|
||||||
WHERE datname = current_database()
|
WHERE datname = current_database()
|
||||||
"""
|
"""
|
||||||
@@ -55,18 +73,44 @@ async def collect_target(target: Target) -> None:
|
|||||||
WHERE datname = current_database()
|
WHERE datname = current_database()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
checkpointer_view_exists = await conn.fetchval("SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL")
|
||||||
|
bgwriter = None
|
||||||
|
if checkpointer_view_exists:
|
||||||
|
try:
|
||||||
|
bgwriter = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
num_timed AS checkpoints_timed,
|
||||||
|
num_requested AS checkpoints_req,
|
||||||
|
0::bigint AS buffers_checkpoint,
|
||||||
|
0::bigint AS buffers_clean,
|
||||||
|
0::bigint AS maxwritten_clean
|
||||||
|
FROM pg_stat_checkpointer
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
bgwriter = None
|
||||||
|
if bgwriter is None:
|
||||||
|
try:
|
||||||
bgwriter = await conn.fetchrow(
|
bgwriter = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
||||||
FROM pg_stat_bgwriter
|
FROM pg_stat_bgwriter
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
bgwriter = None
|
||||||
|
|
||||||
if stat_db is None:
|
if stat_db is None:
|
||||||
stat_db = {
|
stat_db = {
|
||||||
"numbackends": 0,
|
"numbackends": 0,
|
||||||
"xact_commit": 0,
|
"xact_commit": 0,
|
||||||
"xact_rollback": 0,
|
"xact_rollback": 0,
|
||||||
|
"deadlocks": 0,
|
||||||
|
"temp_files": 0,
|
||||||
|
"temp_bytes": 0,
|
||||||
|
"blk_read_time": 0,
|
||||||
|
"blk_write_time": 0,
|
||||||
"blks_hit": 0,
|
"blks_hit": 0,
|
||||||
"blks_read": 0,
|
"blks_read": 0,
|
||||||
"tup_returned": 0,
|
"tup_returned": 0,
|
||||||
@@ -89,6 +133,7 @@ async def collect_target(target: Target) -> None:
|
|||||||
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
|
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
|
||||||
|
|
||||||
query_rows = []
|
query_rows = []
|
||||||
|
if target.use_pg_stat_statements:
|
||||||
try:
|
try:
|
||||||
query_rows = await conn.fetch(
|
query_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
@@ -106,6 +151,13 @@ async def collect_target(target: Target) -> None:
|
|||||||
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
|
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
|
||||||
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
|
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
|
||||||
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
|
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
|
||||||
|
await _store_metric(db, target.id, "xact_commit", stat_db["xact_commit"], {})
|
||||||
|
await _store_metric(db, target.id, "xact_rollback", stat_db["xact_rollback"], {})
|
||||||
|
await _store_metric(db, target.id, "deadlocks", stat_db["deadlocks"], {})
|
||||||
|
await _store_metric(db, target.id, "temp_files", stat_db["temp_files"], {})
|
||||||
|
await _store_metric(db, target.id, "temp_bytes", stat_db["temp_bytes"], {})
|
||||||
|
await _store_metric(db, target.id, "blk_read_time", stat_db["blk_read_time"], {})
|
||||||
|
await _store_metric(db, target.id, "blk_write_time", stat_db["blk_write_time"], {})
|
||||||
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
|
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
|
||||||
await _store_metric(db, target.id, "locks_total", lock_count, {})
|
await _store_metric(db, target.id, "locks_total", lock_count, {})
|
||||||
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
|
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
|
||||||
@@ -133,17 +185,97 @@ async def collect_once() -> None:
|
|||||||
async with SessionLocal() as db:
|
async with SessionLocal() as db:
|
||||||
targets = (await db.scalars(select(Target))).all()
|
targets = (await db.scalars(select(Target))).all()
|
||||||
|
|
||||||
|
active_target_ids = {target.id for target in targets}
|
||||||
|
stale_target_ids = [target_id for target_id in _failure_state.keys() if target_id not in active_target_ids]
|
||||||
|
for stale_target_id in stale_target_ids:
|
||||||
|
_failure_state.pop(stale_target_id, None)
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
state = _failure_state.get(target.id)
|
||||||
|
if state:
|
||||||
|
next_attempt_at = state.get("next_attempt_at")
|
||||||
|
if isinstance(next_attempt_at, datetime) and now < next_attempt_at:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await collect_target(target)
|
await collect_target(target)
|
||||||
|
prev = _failure_state.pop(target.id, None)
|
||||||
|
if prev:
|
||||||
|
first_failure_at = prev.get("first_failure_at")
|
||||||
|
downtime_seconds = None
|
||||||
|
if isinstance(first_failure_at, datetime):
|
||||||
|
downtime_seconds = max(0, int((now - first_failure_at).total_seconds()))
|
||||||
|
logger.info(
|
||||||
|
"collector_target_recovered target=%s after_failures=%s downtime_seconds=%s last_error=%s",
|
||||||
|
target.id,
|
||||||
|
prev.get("count", 0),
|
||||||
|
downtime_seconds,
|
||||||
|
prev.get("error"),
|
||||||
|
)
|
||||||
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
||||||
logger.exception("collector_error target=%s err=%s", target.id, exc)
|
current_error = str(exc)
|
||||||
|
error_class = exc.__class__.__name__
|
||||||
|
state = _failure_state.get(target.id)
|
||||||
|
if state is None:
|
||||||
|
next_delay = min(_backoff_max_seconds, _backoff_base_seconds)
|
||||||
|
jitter = next_delay * _backoff_jitter_factor
|
||||||
|
next_delay = max(1, int(next_delay + uniform(-jitter, jitter)))
|
||||||
|
next_attempt_at = now.timestamp() + next_delay
|
||||||
|
_failure_state[target.id] = {
|
||||||
|
"count": 1,
|
||||||
|
"first_failure_at": now,
|
||||||
|
"last_log_at": now,
|
||||||
|
"error": current_error,
|
||||||
|
"next_attempt_at": datetime.fromtimestamp(next_attempt_at, tz=timezone.utc),
|
||||||
|
}
|
||||||
|
logger.warning(
|
||||||
|
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s retry_in_seconds=%s",
|
||||||
|
target.id,
|
||||||
|
error_class,
|
||||||
|
current_error,
|
||||||
|
1,
|
||||||
|
next_delay,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
count = int(state.get("count", 0)) + 1
|
||||||
|
raw_backoff = min(_backoff_max_seconds, _backoff_base_seconds * (2 ** min(count - 1, 10)))
|
||||||
|
jitter = raw_backoff * _backoff_jitter_factor
|
||||||
|
next_delay = max(1, int(raw_backoff + uniform(-jitter, jitter)))
|
||||||
|
state["next_attempt_at"] = datetime.fromtimestamp(now.timestamp() + next_delay, tz=timezone.utc)
|
||||||
|
|
||||||
|
last_log_at = state.get("last_log_at")
|
||||||
|
last_logged_error = str(state.get("error", ""))
|
||||||
|
should_log = False
|
||||||
|
if current_error != last_logged_error:
|
||||||
|
should_log = True
|
||||||
|
elif isinstance(last_log_at, datetime):
|
||||||
|
should_log = (now - last_log_at).total_seconds() >= _failure_log_interval_seconds
|
||||||
|
else:
|
||||||
|
should_log = True
|
||||||
|
|
||||||
|
state["count"] = count
|
||||||
|
if should_log:
|
||||||
|
state["last_log_at"] = now
|
||||||
|
state["error"] = current_error
|
||||||
|
logger.warning(
|
||||||
|
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s retry_in_seconds=%s",
|
||||||
|
target.id,
|
||||||
|
error_class,
|
||||||
|
current_error,
|
||||||
|
count,
|
||||||
|
next_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def collector_loop(stop_event: asyncio.Event) -> None:
|
async def collector_loop(stop_event: asyncio.Event) -> None:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
cycle_started = asyncio.get_running_loop().time()
|
||||||
await collect_once()
|
await collect_once()
|
||||||
|
elapsed = asyncio.get_running_loop().time() - cycle_started
|
||||||
|
sleep_for = max(0.0, settings.poll_interval_seconds - elapsed)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=settings.poll_interval_seconds)
|
await asyncio.wait_for(stop_event.wait(), timeout=sleep_for)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class DiskSpaceProvider:
|
|||||||
class NullDiskSpaceProvider(DiskSpaceProvider):
|
class NullDiskSpaceProvider(DiskSpaceProvider):
|
||||||
async def get_free_bytes(self, target_host: str) -> DiskSpaceProbeResult:
|
async def get_free_bytes(self, target_host: str) -> DiskSpaceProbeResult:
|
||||||
return DiskSpaceProbeResult(
|
return DiskSpaceProbeResult(
|
||||||
source="none",
|
source="agentless",
|
||||||
status="unavailable",
|
status="unavailable",
|
||||||
free_bytes=None,
|
free_bytes=None,
|
||||||
message=f"No infra probe configured for host {target_host}. Add SSH/Agent provider later.",
|
message=f"Agentless mode: host-level free disk is not available for {target_host}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,25 @@ async def collect_overview(
|
|||||||
errors,
|
errors,
|
||||||
"pg_stat_database_perf",
|
"pg_stat_database_perf",
|
||||||
)
|
)
|
||||||
|
checkpointer_view_exists = await _safe_fetchval(
|
||||||
|
conn,
|
||||||
|
"SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL",
|
||||||
|
errors,
|
||||||
|
"checkpointer_view_exists",
|
||||||
|
)
|
||||||
|
if checkpointer_view_exists:
|
||||||
|
bgwriter = await _safe_fetchrow(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
num_timed AS checkpoints_timed,
|
||||||
|
num_requested AS checkpoints_req
|
||||||
|
FROM pg_stat_checkpointer
|
||||||
|
""",
|
||||||
|
errors,
|
||||||
|
"pg_stat_checkpointer",
|
||||||
|
)
|
||||||
|
else:
|
||||||
bgwriter = await _safe_fetchrow(
|
bgwriter = await _safe_fetchrow(
|
||||||
conn,
|
conn,
|
||||||
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",
|
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",
|
||||||
|
|||||||
91
backend/app/services/service_info.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
UPSTREAM_REPO_WEB = "https://git.nesterovic.cc/nessi/NexaPG"
|
||||||
|
UPSTREAM_REPO_API = "https://git.nesterovic.cc/api/v1/repos/nessi/NexaPG"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_version(payload: str) -> str | None:
|
||||||
|
txt = payload.strip()
|
||||||
|
if not txt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(txt)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("latest_version", "version", "tag_name", "name"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
for key in ("latest_version", "version", "tag_name", "name"):
|
||||||
|
value = data[0].get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
first_line = txt.splitlines()[0].strip()
|
||||||
|
if first_line:
|
||||||
|
return first_line[:64]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_semver(value: str) -> tuple[int, ...] | None:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized.startswith("v"):
|
||||||
|
normalized = normalized[1:]
|
||||||
|
parts = re.findall(r"\d+", normalized)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
return tuple(int(p) for p in parts[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def is_update_available(current_version: str, latest_version: str) -> bool:
|
||||||
|
current = _parse_semver(current_version)
|
||||||
|
latest = _parse_semver(latest_version)
|
||||||
|
if current and latest:
|
||||||
|
max_len = max(len(current), len(latest))
|
||||||
|
current = current + (0,) * (max_len - len(current))
|
||||||
|
latest = latest + (0,) * (max_len - len(latest))
|
||||||
|
return latest > current
|
||||||
|
return latest_version.strip() != current_version.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str):
|
||||||
|
req = Request(url, headers={"User-Agent": "NexaPG/1.0"})
|
||||||
|
with urlopen(req, timeout=8) as response:
|
||||||
|
raw = response.read(64_000).decode("utf-8", errors="replace")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_latest_from_upstream_sync() -> tuple[str, str]:
|
||||||
|
latest_release_url = f"{UPSTREAM_REPO_API}/releases/latest"
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = _get_json(latest_release_url)
|
||||||
|
if isinstance(release, dict):
|
||||||
|
tag = (release.get("tag_name") or release.get("name") or "").strip()
|
||||||
|
if tag:
|
||||||
|
return tag[:64], "release"
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Could not fetch latest release from upstream repository: {exc}") from exc
|
||||||
|
raise ValueError("No published release found in upstream repository")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_latest_from_upstream() -> tuple[str | None, str | None, str | None]:
|
||||||
|
try:
|
||||||
|
latest, ref = await asyncio.to_thread(_fetch_latest_from_upstream_sync)
|
||||||
|
return latest, ref, None
|
||||||
|
except URLError as exc:
|
||||||
|
return None, None, f"Version check failed: {exc.reason}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, None, f"Version check failed: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi==0.116.1
|
fastapi==0.129.0
|
||||||
|
starlette==0.52.1
|
||||||
uvicorn[standard]==0.35.0
|
uvicorn[standard]==0.35.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
sqlalchemy[asyncio]==2.0.44
|
sqlalchemy[asyncio]==2.0.44
|
||||||
@@ -7,7 +8,7 @@ alembic==1.16.5
|
|||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
pydantic-settings==2.11.0
|
pydantic-settings==2.11.0
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
python-jose[cryptography]==3.5.0
|
PyJWT==2.11.0
|
||||||
passlib[argon2]==1.7.4
|
passlib[argon2]==1.7.4
|
||||||
cryptography==45.0.7
|
cryptography==46.0.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.22
|
||||||
|
|||||||
367
backend/scripts/pg_compat_smoke.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
|
||||||
|
def _required_env(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(f"Missing required env var: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _connect_with_retry(dsn: str, attempts: int = 40, delay_seconds: float = 1.5) -> asyncpg.Connection:
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for _ in range(attempts):
|
||||||
|
try:
|
||||||
|
return await asyncpg.connect(dsn=dsn, timeout=5)
|
||||||
|
except Exception as exc: # pragma: no cover - smoke utility
|
||||||
|
last_exc = exc
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
raise RuntimeError(f"Could not connect to PostgreSQL after retries: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetchrow_required(conn: asyncpg.Connection, query: str, label: str) -> dict[str, Any]:
|
||||||
|
row = await conn.fetchrow(query)
|
||||||
|
if row is None:
|
||||||
|
raise RuntimeError(f"{label} returned no rows")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_required_fetchrow(conn: asyncpg.Connection, label: str, query: str) -> None:
|
||||||
|
await _fetchrow_required(conn, query, label)
|
||||||
|
print(f"[compat] PASS required: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_required_fetch(conn: asyncpg.Connection, label: str, query: str) -> None:
|
||||||
|
await conn.fetch(query)
|
||||||
|
print(f"[compat] PASS required: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_required_fetchval(conn: asyncpg.Connection, label: str, query: str) -> None:
|
||||||
|
await conn.fetchval(query)
|
||||||
|
print(f"[compat] PASS required: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_optional(conn: asyncpg.Connection, label: str, query: str) -> None:
|
||||||
|
try:
|
||||||
|
await conn.fetch(query)
|
||||||
|
print(f"[compat] PASS optional: {label}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[compat] SKIP optional: {label} ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_expect_failure(
|
||||||
|
conn: asyncpg.Connection,
|
||||||
|
label: str,
|
||||||
|
query: str,
|
||||||
|
accepted_sqlstates: set[str],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await conn.fetch(query)
|
||||||
|
except asyncpg.PostgresError as exc:
|
||||||
|
if exc.sqlstate in accepted_sqlstates:
|
||||||
|
print(f"[compat] PASS expected-failure: {label} (sqlstate={exc.sqlstate})")
|
||||||
|
return
|
||||||
|
raise RuntimeError(f"{label} failed with unexpected sqlstate={exc.sqlstate}: {exc}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"{label} failed with unexpected non-Postgres error: {exc}") from exc
|
||||||
|
raise RuntimeError(f"{label} unexpectedly succeeded, but failure was expected")
|
||||||
|
|
||||||
|
|
||||||
|
def _section(title: str) -> None:
|
||||||
|
print(f"[compat] --- {title} ---")
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn_candidates() -> list[str]:
|
||||||
|
# Preferred: explicit candidate list for CI portability (Gitea/GitHub runners).
|
||||||
|
raw_candidates = os.getenv("PG_DSN_CANDIDATES", "").strip()
|
||||||
|
if raw_candidates:
|
||||||
|
values = [item.strip() for item in raw_candidates.split(",") if item.strip()]
|
||||||
|
if values:
|
||||||
|
return values
|
||||||
|
# Backward compatible single DSN.
|
||||||
|
raw_single = os.getenv("PG_DSN", "").strip()
|
||||||
|
if raw_single:
|
||||||
|
return [raw_single]
|
||||||
|
raise RuntimeError("Missing PG_DSN or PG_DSN_CANDIDATES")
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
candidates = _dsn_candidates()
|
||||||
|
last_error: Exception | None = None
|
||||||
|
conn: asyncpg.Connection | None = None
|
||||||
|
used_dsn = ""
|
||||||
|
for dsn in candidates:
|
||||||
|
try:
|
||||||
|
conn = await _connect_with_retry(dsn)
|
||||||
|
used_dsn = dsn
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if conn is None:
|
||||||
|
raise RuntimeError(f"Could not connect to PostgreSQL using candidates: {last_error}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = await conn.fetchval("SHOW server_version")
|
||||||
|
current_db = await conn.fetchval("SELECT current_database()")
|
||||||
|
print(f"[compat] Connected: version={version} db={current_db} dsn={used_dsn}")
|
||||||
|
|
||||||
|
_section("connectivity")
|
||||||
|
await _run_required_fetchval(conn, "target_connection.select_1", "SELECT 1")
|
||||||
|
await _run_required_fetchval(conn, "connectivity.server_encoding", "SHOW server_encoding")
|
||||||
|
await _run_required_fetchval(conn, "connectivity.timezone", "SHOW TimeZone")
|
||||||
|
|
||||||
|
_section("collector")
|
||||||
|
# Core collector queries used in app/services/collector.py
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"collector.pg_stat_database",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
numbackends,
|
||||||
|
xact_commit,
|
||||||
|
xact_rollback,
|
||||||
|
deadlocks,
|
||||||
|
temp_files,
|
||||||
|
temp_bytes,
|
||||||
|
blk_read_time,
|
||||||
|
blk_write_time,
|
||||||
|
blks_hit,
|
||||||
|
blks_read,
|
||||||
|
tup_returned,
|
||||||
|
tup_fetched
|
||||||
|
FROM pg_stat_database
|
||||||
|
WHERE datname = current_database()
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"collector.pg_stat_activity",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||||
|
count(*) AS total_connections
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _run_required_fetchval(conn, "collector.pg_locks_count", "SELECT count(*) FROM pg_locks")
|
||||||
|
|
||||||
|
# Checkpoint stats fallback (PG14/15 vs newer changes)
|
||||||
|
has_checkpointer = await conn.fetchval("SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL")
|
||||||
|
if has_checkpointer:
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"collector.checkpointer_view",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
num_timed AS checkpoints_timed,
|
||||||
|
num_requested AS checkpoints_req,
|
||||||
|
0::bigint AS buffers_checkpoint,
|
||||||
|
0::bigint AS buffers_clean,
|
||||||
|
0::bigint AS maxwritten_clean
|
||||||
|
FROM pg_stat_checkpointer
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
print("[compat] Using pg_stat_checkpointer")
|
||||||
|
else:
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"collector.bgwriter_view",
|
||||||
|
"SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean FROM pg_stat_bgwriter",
|
||||||
|
)
|
||||||
|
print("[compat] Using pg_stat_bgwriter")
|
||||||
|
|
||||||
|
_section("target endpoints")
|
||||||
|
# Target endpoint queries used in app/api/routes/targets.py
|
||||||
|
await _run_required_fetch(
|
||||||
|
conn,
|
||||||
|
"target_endpoint.locks_table",
|
||||||
|
"""
|
||||||
|
SELECT locktype, mode, granted, relation::regclass::text AS relation, pid
|
||||||
|
FROM pg_locks
|
||||||
|
ORDER BY granted ASC, mode
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_required_fetch(
|
||||||
|
conn,
|
||||||
|
"target_endpoint.activity_table",
|
||||||
|
"""
|
||||||
|
SELECT pid, usename, application_name, client_addr::text, state, wait_event_type, wait_event, now() - query_start AS running_for, left(query, 300) AS query
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
ORDER BY query_start NULLS LAST
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_required_fetch(
|
||||||
|
conn,
|
||||||
|
"target_endpoint.discover_databases",
|
||||||
|
"""
|
||||||
|
SELECT datname
|
||||||
|
FROM pg_database
|
||||||
|
WHERE datallowconn
|
||||||
|
AND NOT datistemplate
|
||||||
|
ORDER BY datname
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
_section("overview")
|
||||||
|
# Overview queries used in app/services/overview_collector.py
|
||||||
|
await _run_required_fetchval(conn, "overview.show_server_version", "SHOW server_version")
|
||||||
|
await _run_required_fetchval(conn, "overview.pg_is_in_recovery", "SELECT pg_is_in_recovery()")
|
||||||
|
await _run_required_fetchval(conn, "overview.pg_postmaster_start_time", "SELECT pg_postmaster_start_time()")
|
||||||
|
await _run_required_fetchval(conn, "overview.current_database", "SELECT current_database()")
|
||||||
|
await _run_required_fetchval(conn, "overview.inet_server_port", "SELECT inet_server_port()")
|
||||||
|
await _run_required_fetchval(conn, "overview.pg_database_size_current", "SELECT pg_database_size(current_database())")
|
||||||
|
await _run_required_fetch(
|
||||||
|
conn,
|
||||||
|
"overview.pg_database_size_all",
|
||||||
|
"SELECT datname, pg_database_size(datname) AS size_bytes FROM pg_database ORDER BY size_bytes DESC",
|
||||||
|
)
|
||||||
|
await _run_required_fetch(
|
||||||
|
conn,
|
||||||
|
"overview.largest_tables",
|
||||||
|
"""
|
||||||
|
SELECT schemaname, relname, pg_total_relation_size(schemaname || '.' || relname) AS size_bytes
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY size_bytes DESC
|
||||||
|
LIMIT 5
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_required_fetchval(conn, "overview.pg_ls_waldir", "SELECT COALESCE(sum(size), 0) FROM pg_ls_waldir()")
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"overview.performance_pg_stat_database",
|
||||||
|
"""
|
||||||
|
SELECT xact_commit, xact_rollback, deadlocks, temp_files, temp_bytes, blk_read_time, blk_write_time
|
||||||
|
FROM pg_stat_database
|
||||||
|
WHERE datname = current_database()
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_required_fetchval(
|
||||||
|
conn,
|
||||||
|
"overview.autovacuum_activity",
|
||||||
|
"""
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE query ILIKE 'autovacuum:%'
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_required_fetchval(
|
||||||
|
conn,
|
||||||
|
"overview.checkpointer_view_exists",
|
||||||
|
"SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL",
|
||||||
|
)
|
||||||
|
if has_checkpointer:
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"overview.pg_stat_checkpointer",
|
||||||
|
"SELECT num_timed AS checkpoints_timed, num_requested AS checkpoints_req FROM pg_stat_checkpointer",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await _run_required_fetchrow(
|
||||||
|
conn,
|
||||||
|
"overview.pg_stat_bgwriter",
|
||||||
|
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",
|
||||||
|
)
|
||||||
|
|
||||||
|
_section("optional paths")
|
||||||
|
# Optional paths that may depend on role/extension config.
|
||||||
|
await _run_optional(conn, "overview.replication_slots", "SELECT count(*) FROM pg_replication_slots")
|
||||||
|
await _run_optional(
|
||||||
|
conn,
|
||||||
|
"overview.pg_stat_replication",
|
||||||
|
"""
|
||||||
|
SELECT application_name, client_addr::text, state, sync_state,
|
||||||
|
EXTRACT(EPOCH FROM write_lag) AS write_lag_seconds,
|
||||||
|
EXTRACT(EPOCH FROM flush_lag) AS flush_lag_seconds,
|
||||||
|
EXTRACT(EPOCH FROM replay_lag) AS replay_lag_seconds,
|
||||||
|
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
|
||||||
|
FROM pg_stat_replication
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
await _run_optional(
|
||||||
|
conn,
|
||||||
|
"overview.standby_replay_lag",
|
||||||
|
"SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))",
|
||||||
|
)
|
||||||
|
await _run_optional(
|
||||||
|
conn,
|
||||||
|
"collector.pg_stat_statements",
|
||||||
|
"""
|
||||||
|
SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text
|
||||||
|
FROM pg_stat_statements
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
_section("pg_stat_statements modes")
|
||||||
|
# Validate both runtime modes NexaPG must support:
|
||||||
|
# 1) extension unavailable/not preloaded -> query fails with known sqlstate
|
||||||
|
# 2) extension available + loaded -> query succeeds
|
||||||
|
await conn.execute("DROP EXTENSION IF EXISTS pg_stat_statements")
|
||||||
|
await _run_expect_failure(
|
||||||
|
conn,
|
||||||
|
"pg_stat_statements.absent_or_not_loaded",
|
||||||
|
"""
|
||||||
|
SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text
|
||||||
|
FROM pg_stat_statements
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
accepted_sqlstates={"42P01", "55000"},
|
||||||
|
)
|
||||||
|
|
||||||
|
available = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_available_extensions
|
||||||
|
WHERE name = 'pg_stat_statements'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if available:
|
||||||
|
try:
|
||||||
|
await conn.execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[compat] SKIP optional: pg_stat_statements.create_extension ({exc})")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text
|
||||||
|
FROM pg_stat_statements
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("[compat] PASS optional: pg_stat_statements.enabled_query")
|
||||||
|
except asyncpg.PostgresError as exc:
|
||||||
|
# Typical when shared_preload_libraries does not include pg_stat_statements.
|
||||||
|
if exc.sqlstate == "55000":
|
||||||
|
print(f"[compat] SKIP optional: pg_stat_statements.enabled_query ({exc})")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"pg_stat_statements.enabled_query unexpected sqlstate={exc.sqlstate}: {exc}") from exc
|
||||||
|
else:
|
||||||
|
print("[compat] SKIP optional: pg_stat_statements.extension_unavailable")
|
||||||
|
|
||||||
|
print("[compat] Smoke checks passed")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
except Exception as exc: # pragma: no cover - smoke utility
|
||||||
|
print(f"[compat] FAILED: {exc}", file=sys.stderr)
|
||||||
|
raise
|
||||||
192
backend/scripts/pip_audit_gate.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gate pip-audit results with an auditable allowlist policy.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Block unresolved HIGH/CRITICAL vulnerabilities.
|
||||||
|
- If severity is missing, treat as HIGH by default.
|
||||||
|
- Allow temporary exceptions via allowlist with expiry metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SEVERITY_ORDER = {"unknown": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
|
||||||
|
BLOCKING_SEVERITIES = {"high", "critical"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> dt.date:
|
||||||
|
return dt.date.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_severity(value: object) -> str:
|
||||||
|
"""Normalize various pip-audit/osv-style severity payloads."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in SEVERITY_ORDER:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
# CVSS numeric string fallback
|
||||||
|
score = float(v)
|
||||||
|
if score >= 9.0:
|
||||||
|
return "critical"
|
||||||
|
if score >= 7.0:
|
||||||
|
return "high"
|
||||||
|
if score >= 4.0:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
except ValueError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
score = float(value)
|
||||||
|
if score >= 9.0:
|
||||||
|
return "critical"
|
||||||
|
if score >= 7.0:
|
||||||
|
return "high"
|
||||||
|
if score >= 4.0:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
# OSV sometimes returns a list of dicts. Pick the max-known severity.
|
||||||
|
best = "unknown"
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
sev = _normalize_severity(item.get("severity"))
|
||||||
|
if SEVERITY_ORDER.get(sev, 0) > SEVERITY_ORDER.get(best, 0):
|
||||||
|
best = sev
|
||||||
|
return best
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return _normalize_severity(value.get("severity"))
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_allowlist(path: Path) -> tuple[list[dict], list[str]]:
|
||||||
|
if not path.exists():
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
today = dt.date.today()
|
||||||
|
active: list[dict] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
required = {"id", "reason", "approved_by", "issue", "expires_on"}
|
||||||
|
for idx, entry in enumerate(entries, start=1):
|
||||||
|
missing = required - set(entry.keys())
|
||||||
|
if missing:
|
||||||
|
errors.append(f"allowlist entry #{idx} missing keys: {', '.join(sorted(missing))}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
expires = _parse_date(str(entry["expires_on"]))
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"allowlist entry #{idx} has invalid expires_on: {entry['expires_on']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if expires < today:
|
||||||
|
errors.append(
|
||||||
|
f"allowlist entry #{idx} ({entry['id']}) expired on {entry['expires_on']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
active.append(entry)
|
||||||
|
|
||||||
|
return active, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_findings(report: object):
|
||||||
|
# pip-audit JSON can be list[dep] or dict with dependencies.
|
||||||
|
deps = report if isinstance(report, list) else report.get("dependencies", [])
|
||||||
|
for dep in deps:
|
||||||
|
package = dep.get("name", "unknown")
|
||||||
|
version = dep.get("version", "unknown")
|
||||||
|
for vuln in dep.get("vulns", []):
|
||||||
|
vuln_id = vuln.get("id", "unknown")
|
||||||
|
aliases = vuln.get("aliases", []) or []
|
||||||
|
severity = _normalize_severity(vuln.get("severity"))
|
||||||
|
if severity == "unknown":
|
||||||
|
severity = "high" # conservative default for policy safety
|
||||||
|
yield {
|
||||||
|
"package": package,
|
||||||
|
"version": version,
|
||||||
|
"id": vuln_id,
|
||||||
|
"aliases": aliases,
|
||||||
|
"severity": severity,
|
||||||
|
"fix_versions": vuln.get("fix_versions", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowlisted(finding: dict, allowlist: list[dict]) -> bool:
|
||||||
|
ids = {finding["id"], *finding["aliases"]}
|
||||||
|
pkg = finding["package"]
|
||||||
|
for entry in allowlist:
|
||||||
|
entry_pkg = entry.get("package")
|
||||||
|
if entry["id"] in ids and (not entry_pkg or entry_pkg == pkg):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--report", required=True, help="Path to pip-audit JSON report")
|
||||||
|
parser.add_argument("--allowlist", required=True, help="Path to allowlist JSON")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report_path = Path(args.report)
|
||||||
|
allowlist_path = Path(args.allowlist)
|
||||||
|
if not report_path.exists():
|
||||||
|
print(f"[pip-audit-gate] Missing report: {report_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
allowlist, allowlist_errors = _load_allowlist(allowlist_path)
|
||||||
|
if allowlist_errors:
|
||||||
|
print("[pip-audit-gate] Allowlist validation failed:")
|
||||||
|
for err in allowlist_errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
unresolved_blocking: list[dict] = []
|
||||||
|
summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0}
|
||||||
|
ignored = 0
|
||||||
|
|
||||||
|
for finding in _iter_findings(report):
|
||||||
|
sev = finding["severity"]
|
||||||
|
summary[sev] = summary.get(sev, 0) + 1
|
||||||
|
if _is_allowlisted(finding, allowlist):
|
||||||
|
ignored += 1
|
||||||
|
continue
|
||||||
|
if sev in BLOCKING_SEVERITIES:
|
||||||
|
unresolved_blocking.append(finding)
|
||||||
|
|
||||||
|
print("[pip-audit-gate] Summary:")
|
||||||
|
print(
|
||||||
|
f" CRITICAL={summary['critical']} HIGH={summary['high']} "
|
||||||
|
f"MEDIUM={summary['medium']} LOW={summary['low']} ALLOWLISTED={ignored}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if unresolved_blocking:
|
||||||
|
print("[pip-audit-gate] Blocking vulnerabilities found:")
|
||||||
|
for f in unresolved_blocking:
|
||||||
|
aliases = ", ".join(f["aliases"]) if f["aliases"] else "-"
|
||||||
|
fixes = ", ".join(f["fix_versions"]) if f["fix_versions"] else "-"
|
||||||
|
print(
|
||||||
|
f" - {f['severity'].upper()} {f['package']}=={f['version']} "
|
||||||
|
f"id={f['id']} aliases=[{aliases}] fixes=[{fixes}]"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("[pip-audit-gate] No unresolved HIGH/CRITICAL vulnerabilities.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
image: nesterovicit/nexapg-backend:latest
|
||||||
context: ./backend
|
pull_policy: always
|
||||||
container_name: nexapg-backend
|
container_name: nexapg-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -47,16 +47,14 @@ services:
|
|||||||
- "${BACKEND_PORT}:8000"
|
- "${BACKEND_PORT}:8000"
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: nesterovicit/nexapg-frontend:latest
|
||||||
context: ./frontend
|
pull_policy: always
|
||||||
args:
|
|
||||||
VITE_API_URL: ${VITE_API_URL}
|
|
||||||
container_name: nexapg-frontend
|
container_name: nexapg-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:8080"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pg_data:
|
pg_data:
|
||||||
|
|||||||
78
docs/deployment/proxy-production-profile.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Production Proxy Profile (HTTPS)
|
||||||
|
|
||||||
|
This profile defines a secure and repeatable NexaPG deployment behind a reverse proxy.
|
||||||
|
|
||||||
|
## Included Profile Files
|
||||||
|
|
||||||
|
- `ops/profiles/prod/.env.production.example`
|
||||||
|
- `ops/profiles/prod/nginx/nexapg.conf`
|
||||||
|
|
||||||
|
## CORS Recommendations by Environment
|
||||||
|
|
||||||
|
| Environment | Recommended `CORS_ORIGINS` | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `dev` | `*` or local explicit origins | `*` is acceptable only for local/dev usage. |
|
||||||
|
| `staging` | Exact staging UI origins | Example: `https://staging-monitor.example.com` |
|
||||||
|
| `prod` | Exact production UI origin(s) only | No wildcard; use comma-separated HTTPS origins if needed. |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# dev only
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# staging
|
||||||
|
CORS_ORIGINS=https://staging-monitor.example.com
|
||||||
|
|
||||||
|
# prod
|
||||||
|
CORS_ORIGINS=https://monitor.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse Proxy Requirements
|
||||||
|
|
||||||
|
For stable auth, CORS, and request context handling, forward these headers to backend:
|
||||||
|
|
||||||
|
- `Host`
|
||||||
|
- `X-Real-IP`
|
||||||
|
- `X-Forwarded-For`
|
||||||
|
- `X-Forwarded-Proto`
|
||||||
|
- `X-Forwarded-Host`
|
||||||
|
- `X-Forwarded-Port`
|
||||||
|
|
||||||
|
Also forward API paths:
|
||||||
|
|
||||||
|
- `/api/` -> backend service (`:8000`)
|
||||||
|
|
||||||
|
## Mixed-Content Prevention
|
||||||
|
|
||||||
|
NexaPG frontend is designed to avoid mixed-content in HTTPS mode:
|
||||||
|
|
||||||
|
- Build/runtime default API base is relative (`/api/v1`)
|
||||||
|
- `frontend/src/api.js` upgrades `http` API URL to `https` when page runs on HTTPS
|
||||||
|
|
||||||
|
Recommended production setting:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
1. Open app over HTTPS and verify:
|
||||||
|
- login request is `https://.../api/v1/auth/login`
|
||||||
|
- no browser mixed-content errors in console
|
||||||
|
2. Verify CORS behavior:
|
||||||
|
- allowed origin works
|
||||||
|
- unknown origin is blocked
|
||||||
|
3. Verify backend receives forwarded protocol:
|
||||||
|
- proxied responses succeed with no redirect/proto issues
|
||||||
|
|
||||||
|
## CI Validation
|
||||||
|
|
||||||
|
`Proxy Profile Validation` workflow runs static guardrail checks:
|
||||||
|
|
||||||
|
- relative `VITE_API_URL` default
|
||||||
|
- required API proxy path in frontend NGINX config
|
||||||
|
- required forwarded headers
|
||||||
|
- HTTPS mixed-content guard in frontend API resolver
|
||||||
|
- production profile forbids wildcard CORS
|
||||||
BIN
docs/screenshots/admin-settings.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
docs/screenshots/alerts.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
docs/screenshots/dashboard-overview.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
BIN
docs/screenshots/query-insights.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
docs/screenshots/target-detail-dba.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
docs/screenshots/target-detail-easy.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/screenshots/targets-management.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
53
docs/security/dependency-exceptions.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Dependency Security Exception Flow (pip-audit)
|
||||||
|
|
||||||
|
This document defines the auditable exception process for Python dependency vulnerabilities.
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
- CI blocks unresolved `HIGH` and `CRITICAL` dependency vulnerabilities.
|
||||||
|
- If a vulnerability does not provide severity metadata, it is treated as `HIGH` by policy.
|
||||||
|
- Temporary exceptions are allowed only through `ops/security/pip-audit-allowlist.json`.
|
||||||
|
|
||||||
|
## Allowlist Location
|
||||||
|
|
||||||
|
- File: `ops/security/pip-audit-allowlist.json`
|
||||||
|
- Format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "CVE-2026-12345",
|
||||||
|
"package": "example-package",
|
||||||
|
"reason": "Upstream fix not released yet",
|
||||||
|
"approved_by": "security-owner",
|
||||||
|
"issue": "NX-202",
|
||||||
|
"expires_on": "2026-12-31"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Fields
|
||||||
|
|
||||||
|
- `id`: Vulnerability ID (`CVE-*`, `GHSA-*`, or advisory ID)
|
||||||
|
- `reason`: Why exception is necessary
|
||||||
|
- `approved_by`: Approver identity
|
||||||
|
- `issue`: Tracking issue/ticket
|
||||||
|
- `expires_on`: Expiry date in `YYYY-MM-DD`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `package`: Restrict exception to one dependency package
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Expired allowlist entries fail CI.
|
||||||
|
- Missing required fields fail CI.
|
||||||
|
- Exceptions must be time-limited and linked to a tracking issue.
|
||||||
|
- Removing an exception is required once an upstream fix is available.
|
||||||
|
|
||||||
|
## Auditability
|
||||||
|
|
||||||
|
- Every exception change is tracked in Git history and code review.
|
||||||
|
- CI logs include blocked vulnerabilities and allowlisted findings counts.
|
||||||
74
docs/security/secret-management.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Secret Management (Production)
|
||||||
|
|
||||||
|
This guide defines secure handling for NexaPG secrets in production deployments.
|
||||||
|
|
||||||
|
## In Scope Secrets
|
||||||
|
|
||||||
|
- `JWT_SECRET_KEY`
|
||||||
|
- `ENCRYPTION_KEY`
|
||||||
|
- `DB_PASSWORD`
|
||||||
|
- SMTP credentials (configured in Admin Settings, encrypted at rest)
|
||||||
|
|
||||||
|
## Do / Don't
|
||||||
|
|
||||||
|
## Do
|
||||||
|
|
||||||
|
- Use an external secret source (Vault, cloud secret manager, orchestrator secrets, or CI/CD secret injection).
|
||||||
|
- Keep secrets out of Git history and out of image layers.
|
||||||
|
- Use strong random values:
|
||||||
|
- JWT secret: at least 32+ bytes random
|
||||||
|
- Fernet key: generated via `Fernet.generate_key()`
|
||||||
|
- Restrict access to runtime secrets (least privilege).
|
||||||
|
- Rotate secrets on schedule and on incident.
|
||||||
|
- Store production `.env` with strict permissions if file-based injection is used:
|
||||||
|
- owner-only read/write (e.g., `chmod 600 .env`)
|
||||||
|
- Audit who can read/update secrets in your deployment platform.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
|
||||||
|
- Do **not** hardcode secrets in source code.
|
||||||
|
- Do **not** commit `.env` with real values.
|
||||||
|
- Do **not** bake production secrets into Dockerfiles or image build args.
|
||||||
|
- Do **not** share secrets in tickets, chat logs, or CI console output.
|
||||||
|
- Do **not** reuse the same secrets between environments.
|
||||||
|
|
||||||
|
## Recommended Secret Providers
|
||||||
|
|
||||||
|
Pick one of these models:
|
||||||
|
|
||||||
|
1. Platform/Cloud secrets
|
||||||
|
- AWS Secrets Manager
|
||||||
|
- Azure Key Vault
|
||||||
|
- Google Secret Manager
|
||||||
|
2. HashiCorp Vault
|
||||||
|
3. CI/CD secret injection
|
||||||
|
- Inject as runtime env vars during deployment
|
||||||
|
4. Docker/Kubernetes secrets
|
||||||
|
- Prefer secret mounts or orchestrator-native secret stores
|
||||||
|
|
||||||
|
If you use plain `.env` files, treat them as sensitive artifacts and protect at OS and backup level.
|
||||||
|
|
||||||
|
## Rotation Basics
|
||||||
|
|
||||||
|
Minimum baseline:
|
||||||
|
|
||||||
|
1. `JWT_SECRET_KEY`
|
||||||
|
- Rotate on schedule (e.g., quarterly) and immediately after compromise.
|
||||||
|
- Expect existing sessions/tokens to become invalid after rotation.
|
||||||
|
2. `ENCRYPTION_KEY`
|
||||||
|
- Rotate with planned maintenance.
|
||||||
|
- Re-encrypt stored encrypted values (target passwords, SMTP password) during key transition.
|
||||||
|
3. `DB_PASSWORD`
|
||||||
|
- Rotate service account credentials regularly.
|
||||||
|
- Apply password changes in DB and deployment config atomically.
|
||||||
|
4. SMTP credentials
|
||||||
|
- Use dedicated sender account/app password.
|
||||||
|
- Rotate regularly and after provider-side security alerts.
|
||||||
|
|
||||||
|
## Operational Checklist
|
||||||
|
|
||||||
|
- [ ] No production secret in repository files.
|
||||||
|
- [ ] No production secret in container image metadata or build args.
|
||||||
|
- [ ] Runtime secret source documented for your environment.
|
||||||
|
- [ ] Secret rotation owner and schedule defined.
|
||||||
|
- [ ] Incident runbook includes emergency rotation steps.
|
||||||
@@ -7,8 +7,14 @@ ARG VITE_API_URL=/api/v1
|
|||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
FROM nginx:1-alpine-slim
|
||||||
|
RUN apk upgrade --no-cache \
|
||||||
|
&& mkdir -p /var/cache/nginx /var/run /var/log/nginx /tmp/nginx \
|
||||||
|
&& chown -R nginx:nginx /var/cache/nginx /var/run /var/log/nginx /tmp/nginx \
|
||||||
|
&& sed -i 's#pid[[:space:]]\+/run/nginx.pid;#pid /tmp/nginx/nginx.pid;#' /etc/nginx/nginx.conf \
|
||||||
|
&& sed -i 's#pid[[:space:]]\+/var/run/nginx.pid;#pid /tmp/nginx/nginx.pid;#' /etc/nginx/nginx.conf
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
USER 101
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD nginx -t || exit 1
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>NexaPG Monitor</title>
|
<title>NexaPG Monitor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
2285
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 172 KiB |
2285
frontend/public/nexapg-logo.svg
Normal file
|
After Width: | Height: | Size: 172 KiB |
@@ -1,12 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "./state";
|
import { useAuth } from "./state";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { TargetsPage } from "./pages/TargetsPage";
|
import { TargetsPage } from "./pages/TargetsPage";
|
||||||
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
||||||
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
||||||
|
import { AlertsPage } from "./pages/AlertsPage";
|
||||||
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||||
|
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
|
||||||
|
import { UserSettingsPage } from "./pages/UserSettingsPage";
|
||||||
|
|
||||||
function Protected({ children }) {
|
function Protected({ children }) {
|
||||||
const { tokens } = useAuth();
|
const { tokens } = useAuth();
|
||||||
@@ -16,40 +19,136 @@ function Protected({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
const { me, logout } = useAuth();
|
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
||||||
|
const fullName = [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<img src="/nexapg-logo.svg" alt="NexaPG" className="brand-logo" />
|
||||||
<h1>NexaPG</h1>
|
<h1>NexaPG</h1>
|
||||||
|
</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<NavLink to="/" end className={navClass}>
|
<NavLink to="/" end className={navClass}>
|
||||||
<span className="nav-icon">DB</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 6c0 1.7 3.6 3 8 3s8-1.3 8-3M4 18c0 1.7 3.6 3 8 3s8-1.3 8-3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Dashboard</span>
|
<span className="nav-label">Dashboard</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/targets" className={navClass}>
|
<NavLink to="/targets" className={navClass}>
|
||||||
<span className="nav-icon">TG</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3zM12 12l8-4.5M12 12L4 7.5M12 12v9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Targets</span>
|
<span className="nav-label">Targets</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/query-insights" className={navClass}>
|
<NavLink to="/query-insights" className={navClass}>
|
||||||
<span className="nav-icon">QI</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M4 19h16M7 15l3-3 3 2 4-5M18 8h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Query Insights</span>
|
<span className="nav-label">Query Insights</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/alerts" className={navClass}>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V10a6 6 0 0 0-12 0v4.2a2 2 0 0 1-.6 1.4L4 17h5m6 0a3 3 0 0 1-6 0" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Alerts</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/service-info"
|
||||||
|
className={({ isActive }) => `nav-btn${isActive ? " active" : ""}${serviceUpdateAvailable ? " update-available" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm0-11v6m0-10h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Service Information</span>
|
||||||
|
</NavLink>
|
||||||
{me?.role === "admin" && (
|
{me?.role === "admin" && (
|
||||||
<NavLink to="/admin/users" className={navClass}>
|
<>
|
||||||
<span className="nav-icon">AD</span>
|
<div className="sidebar-nav-spacer" aria-hidden="true" />
|
||||||
|
<NavLink to="/admin/users" className={({ isActive }) => `nav-btn admin-nav${isActive ? " active" : ""}`}>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm-7 8a7 7 0 0 1 14 0" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Admin</span>
|
<span className="nav-label">Admin</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div>{me?.email}</div>
|
<div className="mode-switch-block">
|
||||||
<div className="role">{me?.role}</div>
|
<div className="mode-switch-label">View Mode</div>
|
||||||
|
<button
|
||||||
|
className={`mode-toggle ${uiMode === "easy" ? "easy" : "dba"}`}
|
||||||
|
onClick={() => setUiMode(uiMode === "easy" ? "dba" : "easy")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="mode-pill">Easy</span>
|
||||||
|
<span className="mode-pill">DBA</span>
|
||||||
|
</button>
|
||||||
|
<small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small>
|
||||||
|
</div>
|
||||||
|
<div className="profile-name">{fullName || me?.email}</div>
|
||||||
|
{fullName && <div className="profile-email">{me?.email}</div>}
|
||||||
|
<div className="role profile-role">{me?.role}</div>
|
||||||
|
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
|
||||||
|
User Settings
|
||||||
|
</NavLink>
|
||||||
<button className="logout-btn" onClick={logout}>Logout</button>
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||||
|
{alertToasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}${toast.closing ? " closing" : ""}`}>
|
||||||
|
<div className="alert-toast-head">
|
||||||
|
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
|
||||||
|
<div className="toast-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toast-view"
|
||||||
|
title="Open in Alerts"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/alerts?open=${encodeURIComponent(toast.alertKey || "")}`);
|
||||||
|
dismissAlertToast(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path
|
||||||
|
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="alert-toast-title">{toast.title}</div>
|
||||||
|
<div className="alert-toast-target">{toast.target}</div>
|
||||||
|
<div className="alert-toast-message">{toast.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,6 +167,9 @@ export function App() {
|
|||||||
<Route path="/targets" element={<TargetsPage />} />
|
<Route path="/targets" element={<TargetsPage />} />
|
||||||
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
||||||
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
||||||
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
|
<Route path="/service-info" element={<ServiceInfoPage />} />
|
||||||
|
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -35,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const raw = await res.text();
|
||||||
throw new Error(txt || `HTTP ${res.status}`);
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = parsed?.message || raw || `HTTP ${res.status}`;
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = res.status;
|
||||||
|
err.code = parsed?.code || null;
|
||||||
|
err.details = parsed?.details || null;
|
||||||
|
err.requestId = parsed?.request_id || res.headers.get("x-request-id") || null;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -2,27 +2,87 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const TEMPLATE_VARIABLES = [
|
||||||
|
"target_name",
|
||||||
|
"target_id",
|
||||||
|
"alert_name",
|
||||||
|
"severity",
|
||||||
|
"category",
|
||||||
|
"description",
|
||||||
|
"message",
|
||||||
|
"value",
|
||||||
|
"warning_threshold",
|
||||||
|
"alert_threshold",
|
||||||
|
"checked_at",
|
||||||
|
"alert_key",
|
||||||
|
];
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const { tokens, refresh, me } = useAuth();
|
const { tokens, refresh, me } = useAuth();
|
||||||
|
const emptyCreateForm = { email: "", first_name: "", last_name: "", password: "", role: "viewer" };
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
|
const [form, setForm] = useState(emptyCreateForm);
|
||||||
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
|
||||||
|
const [emailSettings, setEmailSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_name: "",
|
||||||
|
from_email: "",
|
||||||
|
use_starttls: true,
|
||||||
|
use_ssl: false,
|
||||||
|
warning_subject_template: "",
|
||||||
|
alert_subject_template: "",
|
||||||
|
warning_body_template: "",
|
||||||
|
alert_body_template: "",
|
||||||
|
});
|
||||||
|
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
|
||||||
|
const [testRecipient, setTestRecipient] = useState("");
|
||||||
|
const [smtpInfo, setSmtpInfo] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
|
const [userRows, smtp] = await Promise.all([
|
||||||
|
apiFetch("/admin/users", {}, tokens, refresh),
|
||||||
|
apiFetch("/admin/settings/email", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setUsers(userRows);
|
||||||
|
setEmailSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!smtp.enabled,
|
||||||
|
smtp_host: smtp.smtp_host || "",
|
||||||
|
smtp_port: smtp.smtp_port || 587,
|
||||||
|
smtp_username: smtp.smtp_username || "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_name: smtp.from_name || "",
|
||||||
|
from_email: smtp.from_email || "",
|
||||||
|
use_starttls: !!smtp.use_starttls,
|
||||||
|
use_ssl: !!smtp.use_ssl,
|
||||||
|
warning_subject_template: smtp.warning_subject_template || "",
|
||||||
|
alert_subject_template: smtp.alert_subject_template || "",
|
||||||
|
warning_body_template: smtp.warning_body_template || "",
|
||||||
|
alert_body_template: smtp.alert_body_template || "",
|
||||||
|
}));
|
||||||
|
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
|
||||||
|
setTestRecipient(smtp.from_email || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
||||||
}, [me]);
|
}, [me]);
|
||||||
|
|
||||||
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
|
if (me?.role !== "admin") return <div className="card">Admins only.</div>;
|
||||||
|
|
||||||
const create = async (e) => {
|
const create = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
|
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
|
||||||
setForm({ email: "", password: "", role: "viewer" });
|
setForm(emptyCreateForm);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
@@ -38,30 +98,158 @@ export function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEdit = (user) => {
|
||||||
|
setEditingUserId(user.id);
|
||||||
|
setEditForm({
|
||||||
|
email: user.email || "",
|
||||||
|
first_name: user.first_name || "",
|
||||||
|
last_name: user.last_name || "",
|
||||||
|
password: "",
|
||||||
|
role: user.role || "viewer",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingUserId(null);
|
||||||
|
setEditForm({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (userId) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
email: editForm.email,
|
||||||
|
first_name: editForm.first_name.trim() || null,
|
||||||
|
last_name: editForm.last_name.trim() || null,
|
||||||
|
role: editForm.role,
|
||||||
|
};
|
||||||
|
if (editForm.password.trim()) payload.password = editForm.password;
|
||||||
|
await apiFetch(`/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
cancelEdit();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSmtp = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...emailSettings,
|
||||||
|
smtp_host: emailSettings.smtp_host.trim() || null,
|
||||||
|
smtp_username: emailSettings.smtp_username.trim() || null,
|
||||||
|
from_name: emailSettings.from_name.trim() || null,
|
||||||
|
from_email: emailSettings.from_email.trim() || null,
|
||||||
|
smtp_password: emailSettings.smtp_password || null,
|
||||||
|
warning_subject_template: emailSettings.warning_subject_template.trim() || null,
|
||||||
|
alert_subject_template: emailSettings.alert_subject_template.trim() || null,
|
||||||
|
warning_body_template: emailSettings.warning_body_template.trim() || null,
|
||||||
|
alert_body_template: emailSettings.alert_body_template.trim() || null,
|
||||||
|
};
|
||||||
|
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
setSmtpInfo("SMTP settings saved.");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTestMail = async () => {
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const recipient = testRecipient.trim();
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error("Please enter a test recipient email.");
|
||||||
|
}
|
||||||
|
await apiFetch(
|
||||||
|
"/admin/settings/email/test",
|
||||||
|
{ method: "POST", body: JSON.stringify({ recipient }) },
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setSmtpInfo(`Test email sent to ${recipient}.`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolMode = emailSettings.use_ssl ? "ssl" : emailSettings.use_starttls ? "starttls" : "plain";
|
||||||
|
const setProtocolMode = (mode) => {
|
||||||
|
if (mode === "ssl") {
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: true, use_starttls: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "starttls") {
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="admin-settings-page">
|
||||||
<h2>Admin Users</h2>
|
<h2>Admin Settings</h2>
|
||||||
|
<p className="muted admin-page-subtitle">Manage users and outgoing notifications for this NexaPG instance.</p>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
<form className="card grid three" onSubmit={create}>
|
|
||||||
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
<div className="card">
|
||||||
|
<div className="admin-section-head">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<p className="muted">Create accounts and manage access roles.</p>
|
||||||
|
</div>
|
||||||
|
<form className="grid three admin-user-form" onSubmit={create}>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>First Name</label>
|
||||||
|
<input
|
||||||
|
value={form.first_name}
|
||||||
|
placeholder="Jane"
|
||||||
|
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Last Name</label>
|
||||||
|
<input
|
||||||
|
value={form.last_name}
|
||||||
|
placeholder="Doe"
|
||||||
|
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
placeholder="passwort"
|
placeholder="Set initial password"
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Role</label>
|
||||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
<option value="viewer">viewer</option>
|
<option value="viewer">viewer</option>
|
||||||
<option value="operator">operator</option>
|
<option value="operator">operator</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
</select>
|
</select>
|
||||||
<button>User anlegen</button>
|
</div>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit">Create User</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="card">
|
</div>
|
||||||
|
|
||||||
|
<div className="card admin-users-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
@@ -69,16 +257,273 @@ export function AdminUsersPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id} className="admin-user-row">
|
||||||
<td>{u.id}</td>
|
<td className="user-col-id">{u.id}</td>
|
||||||
<td>{u.email}</td>
|
<td className="user-col-name">
|
||||||
<td>{u.role}</td>
|
{editingUserId === u.id ? (
|
||||||
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
|
<div className="admin-inline-grid two">
|
||||||
|
<input
|
||||||
|
value={editForm.first_name}
|
||||||
|
placeholder="First name"
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, first_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={editForm.last_name}
|
||||||
|
placeholder="Last name"
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, last_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="user-col-name-value">{[u.first_name, u.last_name].filter(Boolean).join(" ") || "-"}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="user-col-email">
|
||||||
|
{editingUserId === u.id ? (
|
||||||
|
<input
|
||||||
|
value={editForm.email}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
u.email
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingUserId === u.id ? (
|
||||||
|
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value })}>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="operator">operator</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-user-actions">
|
||||||
|
{editingUserId === u.id && (
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="admin-inline-password"
|
||||||
|
value={editForm.password}
|
||||||
|
placeholder="New password (optional)"
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editingUserId === u.id ? (
|
||||||
|
<>
|
||||||
|
<button className="table-action-btn primary small-btn" onClick={() => saveEdit(u.id)}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button className="table-action-btn small-btn" onClick={cancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="table-action-btn edit small-btn" onClick={() => startEdit(u)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{u.id !== me.id && (
|
||||||
|
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12">
|
||||||
|
<path
|
||||||
|
d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="admin-section-head">
|
||||||
|
<h3>Alert Email Notifications (SMTP)</h3>
|
||||||
|
<p className="muted">Configure send-only SMTP for warning and alert notifications.</p>
|
||||||
|
</div>
|
||||||
|
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
|
||||||
|
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
|
||||||
|
<div className="admin-subcard field-full">
|
||||||
|
<h4>SMTP Settings</h4>
|
||||||
|
<div className="grid two">
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.enabled}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>
|
||||||
|
<strong>Enable alert emails</strong>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP host</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_host}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={emailSettings.smtp_port}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP username</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_username}
|
||||||
|
placeholder="alerts@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={emailSettings.smtp_password}
|
||||||
|
placeholder={smtpState.has_password ? "Stored (enter to replace)" : "Set password"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>From name</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.from_name}
|
||||||
|
placeholder="NexaPG Alerts"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>From email</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.from_email}
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Transport mode</label>
|
||||||
|
<div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "starttls" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "starttls"}
|
||||||
|
onClick={() => setProtocolMode("starttls")}
|
||||||
|
>
|
||||||
|
STARTTLS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "ssl" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "ssl"}
|
||||||
|
onClick={() => setProtocolMode("ssl")}
|
||||||
|
>
|
||||||
|
SSL/TLS (SMTPS)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "plain" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "plain"}
|
||||||
|
onClick={() => setProtocolMode("plain")}
|
||||||
|
>
|
||||||
|
No TLS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted">Select exactly one mode to avoid STARTTLS/SSL conflicts.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.clear_smtp_password}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, clear_smtp_password: e.target.checked, smtp_password: e.target.checked ? "" : emailSettings.smtp_password })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>Clear stored SMTP password</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-subcard field-full">
|
||||||
|
<h4>Template Settings</h4>
|
||||||
|
<p className="muted template-help-text">
|
||||||
|
If a template field is left empty, NexaPG automatically uses the built-in default template.
|
||||||
|
</p>
|
||||||
|
<div className="template-vars-grid">
|
||||||
|
{TEMPLATE_VARIABLES.map((item) => (
|
||||||
|
<code key={item} className="template-var-pill">
|
||||||
|
{"{" + item + "}"}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Warning subject template</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.warning_subject_template}
|
||||||
|
placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Alert subject template</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.alert_subject_template}
|
||||||
|
placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Warning body template</label>
|
||||||
|
<textarea
|
||||||
|
value={emailSettings.warning_body_template}
|
||||||
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nChecked At: {checked_at}"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_body_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Alert body template</label>
|
||||||
|
<textarea
|
||||||
|
value={emailSettings.alert_body_template}
|
||||||
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nCurrent Value: {value}\nWarning Threshold: {warning_threshold}\nAlert Threshold: {alert_threshold}\nChecked At: {checked_at}\nAlert Key: {alert_key}"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_body_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<input
|
||||||
|
className="admin-test-recipient"
|
||||||
|
value={testRecipient}
|
||||||
|
placeholder="test recipient email"
|
||||||
|
onChange={(e) => setTestRecipient(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="secondary-btn" type="button" onClick={sendTestMail}>Send Test Mail</button>
|
||||||
|
<button className="primary-btn" type="submit">Save SMTP Settings</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted field-full">
|
||||||
|
Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"}
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
524
frontend/src/pages/AlertsPage.jsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
target_id: "",
|
||||||
|
sql_text: "SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'",
|
||||||
|
comparison: "gte",
|
||||||
|
warning_threshold: "",
|
||||||
|
alert_threshold: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAlertValue(value) {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (Number.isInteger(value)) return String(value);
|
||||||
|
return Number(value).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(ts) {
|
||||||
|
if (!ts) return "-";
|
||||||
|
return new Date(ts).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlertSuggestions(item) {
|
||||||
|
const name = (item?.name || "").toLowerCase();
|
||||||
|
const category = (item?.category || "").toLowerCase();
|
||||||
|
const source = (item?.source || "").toLowerCase();
|
||||||
|
const value = Number(item?.value || 0);
|
||||||
|
const suggestions = [];
|
||||||
|
|
||||||
|
if (name.includes("reachability") || name.includes("connectivity") || category === "availability") {
|
||||||
|
suggestions.push("Verify host, port, firewall rules, and network routing between backend container and DB target.");
|
||||||
|
suggestions.push("Check PostgreSQL `listen_addresses` and `pg_hba.conf` on the monitored instance.");
|
||||||
|
}
|
||||||
|
if (name.includes("freshness") || item?.message?.toLowerCase().includes("no metrics")) {
|
||||||
|
suggestions.push("Check collector logs and polling interval. Confirm the target credentials are still valid.");
|
||||||
|
suggestions.push("Run a manual connection test in Targets Management and verify SSL mode.");
|
||||||
|
}
|
||||||
|
if (name.includes("cache hit") || category === "performance") {
|
||||||
|
suggestions.push("Inspect slow queries and add/adjust indexes for frequent WHERE/JOIN columns.");
|
||||||
|
suggestions.push("Review shared buffers and query patterns that cause high disk reads.");
|
||||||
|
}
|
||||||
|
if (name.includes("lock") || category === "contention") {
|
||||||
|
suggestions.push("Inspect blocking sessions in `pg_stat_activity` and long transactions.");
|
||||||
|
suggestions.push("Reduce transaction scope/duration and add missing indexes to avoid lock escalation.");
|
||||||
|
}
|
||||||
|
if (name.includes("deadlock")) {
|
||||||
|
suggestions.push("Enforce a consistent table access order in transactions to prevent deadlocks.");
|
||||||
|
suggestions.push("Retry deadlocked transactions in the application with backoff.");
|
||||||
|
}
|
||||||
|
if (name.includes("checkpoint") || category === "io") {
|
||||||
|
suggestions.push("Review `max_wal_size`, `checkpoint_timeout`, and write burst patterns.");
|
||||||
|
suggestions.push("Check disk throughput and WAL pressure during peak load.");
|
||||||
|
}
|
||||||
|
if (name.includes("rollback")) {
|
||||||
|
suggestions.push("Investigate application errors causing transaction rollbacks.");
|
||||||
|
suggestions.push("Validate constraints/input earlier to reduce failed writes.");
|
||||||
|
}
|
||||||
|
if (name.includes("query") || category === "query" || source === "custom") {
|
||||||
|
suggestions.push("Run `EXPLAIN (ANALYZE, BUFFERS)` for the affected query and optimize highest-cost nodes.");
|
||||||
|
suggestions.push("Prioritize fixes for high total-time queries first, then high mean-time queries.");
|
||||||
|
}
|
||||||
|
if (value > 0 && item?.comparison && item?.alert_threshold !== null && item?.alert_threshold !== undefined) {
|
||||||
|
suggestions.push(`Current value is ${value.toFixed(2)} with threshold rule ${item.comparison} ${Number(item.alert_threshold).toFixed(2)}.`);
|
||||||
|
}
|
||||||
|
if (!suggestions.length) {
|
||||||
|
suggestions.push("Start with target activity, locks, and query insights to isolate the root cause.");
|
||||||
|
suggestions.push("Compare current values to the last stable period and tune threshold sensitivity if needed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { tokens, refresh, me, alertStatus } = useAuth();
|
||||||
|
const [targets, setTargets] = useState([]);
|
||||||
|
const [definitions, setDefinitions] = useState([]);
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [expandedKey, setExpandedKey] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [standardReference, setStandardReference] = useState([]);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const canManageAlerts = me?.role === "admin" || me?.role === "operator";
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const [targetRows, referenceRows] = await Promise.all([
|
||||||
|
apiFetch("/targets", {}, tokens, refresh),
|
||||||
|
apiFetch("/alerts/standard-reference", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setTargets(targetRows);
|
||||||
|
setStandardReference(Array.isArray(referenceRows) ? referenceRows : []);
|
||||||
|
|
||||||
|
if (canManageAlerts) {
|
||||||
|
const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh);
|
||||||
|
setDefinitions(defs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll();
|
||||||
|
}, [canManageAlerts]);
|
||||||
|
|
||||||
|
const targetOptions = useMemo(
|
||||||
|
() => [{ id: "", name: "All targets" }, ...targets.map((t) => ({ id: String(t.id), name: `${t.name} (${t.host}:${t.port})` }))],
|
||||||
|
[targets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDefinition = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setTestResult("");
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
"/alerts/definitions",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
target_id: form.target_id ? Number(form.target_id) : null,
|
||||||
|
sql_text: form.sql_text,
|
||||||
|
comparison: form.comparison,
|
||||||
|
warning_threshold: form.warning_threshold === "" ? null : Number(form.warning_threshold),
|
||||||
|
alert_threshold: Number(form.alert_threshold),
|
||||||
|
enabled: !!form.enabled,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setForm(initialForm);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDefinition = async () => {
|
||||||
|
if (!form.target_id) {
|
||||||
|
setTestResult("Select a specific target to test this SQL query.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult("");
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(
|
||||||
|
"/alerts/definitions/test",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
target_id: Number(form.target_id),
|
||||||
|
sql_text: form.sql_text,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
setTestResult(`Query test succeeded. Returned value: ${formatAlertValue(res.value)}`);
|
||||||
|
} else {
|
||||||
|
setTestResult(`Query test failed: ${res.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDefinition = async (definitionId) => {
|
||||||
|
if (!confirm("Delete this custom alert definition?")) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/alerts/definitions/${definitionId}`, { method: "DELETE" }, tokens, refresh);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDefinition = async (definition) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
`/alerts/definitions/${definition.id}`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ enabled: !definition.enabled }) },
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = (key) => {
|
||||||
|
setExpandedKey((prev) => (prev === key ? "" : key));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const openKey = params.get("open");
|
||||||
|
if (!openKey) return;
|
||||||
|
setExpandedKey(openKey);
|
||||||
|
const id = `alert-item-${openKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 120);
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
if (loading) return <div className="card">Loading alerts...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alerts-page">
|
||||||
|
<h2>Alerts</h2>
|
||||||
|
<p className="alerts-subtitle">Warnings are early signals. Alerts are critical thresholds reached or exceeded.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid two alerts-kpis">
|
||||||
|
<div className="card alerts-kpi warning">
|
||||||
|
<strong>{alertStatus.warning_count || 0}</strong>
|
||||||
|
<span>Warnings</span>
|
||||||
|
</div>
|
||||||
|
<div className="card alerts-kpi alert">
|
||||||
|
<strong>{alertStatus.alert_count || 0}</strong>
|
||||||
|
<span>Alerts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<section className="card">
|
||||||
|
<h3>Warnings</h3>
|
||||||
|
{alertStatus.warnings?.length ? (
|
||||||
|
<div className="alerts-list">
|
||||||
|
{alertStatus.warnings.map((item) => {
|
||||||
|
const isOpen = expandedKey === item.alert_key;
|
||||||
|
const suggestions = buildAlertSuggestions(item);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`alert-item warning ${isOpen ? "is-open" : ""}`}
|
||||||
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="alert-item-head">
|
||||||
|
<span className="alert-badge warning">Warning</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.target_name}</small>
|
||||||
|
</div>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="alert-message">{item.message}</p>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="alert-details-grid">
|
||||||
|
<div><span>Source</span><strong>{item.source}</strong></div>
|
||||||
|
<div><span>Category</span><strong>{item.category}</strong></div>
|
||||||
|
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
|
||||||
|
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
|
||||||
|
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
|
||||||
|
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
|
||||||
|
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
|
||||||
|
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
|
||||||
|
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
|
||||||
|
<div className="alert-suggestions">
|
||||||
|
<h4>Recommended actions</h4>
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No warning-level alerts right now.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Alerts</h3>
|
||||||
|
{alertStatus.alerts?.length ? (
|
||||||
|
<div className="alerts-list">
|
||||||
|
{alertStatus.alerts.map((item) => {
|
||||||
|
const isOpen = expandedKey === item.alert_key;
|
||||||
|
const suggestions = buildAlertSuggestions(item);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`alert-item alert ${isOpen ? "is-open" : ""}`}
|
||||||
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="alert-item-head">
|
||||||
|
<span className="alert-badge alert">Alert</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.target_name}</small>
|
||||||
|
</div>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="alert-message">{item.message}</p>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="alert-details-grid">
|
||||||
|
<div><span>Source</span><strong>{item.source}</strong></div>
|
||||||
|
<div><span>Category</span><strong>{item.category}</strong></div>
|
||||||
|
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
|
||||||
|
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
|
||||||
|
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
|
||||||
|
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
|
||||||
|
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
|
||||||
|
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
|
||||||
|
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
|
||||||
|
<div className="alert-suggestions">
|
||||||
|
<h4>Recommended actions</h4>
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No critical alerts right now.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Standard Alert Reference</h3>
|
||||||
|
<p>What each built-in alert checks and which default thresholds are used.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<div className="standard-alerts-note">
|
||||||
|
Some rules include traffic guards to reduce noise (for example rollback ratio needs enough transactions).
|
||||||
|
</div>
|
||||||
|
<div className="standard-alerts-table-wrap">
|
||||||
|
<table className="standard-alerts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Checks</th>
|
||||||
|
<th>Comparison</th>
|
||||||
|
<th>Warning</th>
|
||||||
|
<th>Alert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{standardReference.length > 0 ? (
|
||||||
|
standardReference.map((row) => (
|
||||||
|
<tr key={row.key || row.name}>
|
||||||
|
<td>{row.name}</td>
|
||||||
|
<td>{row.checks}</td>
|
||||||
|
<td><code>{row.comparison}</code></td>
|
||||||
|
<td>{row.warning}</td>
|
||||||
|
<td>{row.alert}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="muted">No standard alert metadata available.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{canManageAlerts && (
|
||||||
|
<>
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Create Custom Alert</h3>
|
||||||
|
<p>Admins and operators can add SQL-based checks with warning and alert thresholds.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<form className="alert-form grid two" onSubmit={createDefinition}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. High Active Sessions" required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Target Scope</label>
|
||||||
|
<select value={form.target_id} onChange={(e) => setForm({ ...form, target_id: e.target.value })}>
|
||||||
|
{targetOptions.map((opt) => (
|
||||||
|
<option key={opt.id || "all"} value={opt.id}>
|
||||||
|
{opt.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Comparison</label>
|
||||||
|
<select value={form.comparison} onChange={(e) => setForm({ ...form, comparison: e.target.value })}>
|
||||||
|
<option value="gte">greater than or equal (>=)</option>
|
||||||
|
<option value="gt">greater than (>)</option>
|
||||||
|
<option value="lte">less than or equal (<=)</option>
|
||||||
|
<option value="lt">less than (<)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this check validate?" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Warning Threshold (optional)</label>
|
||||||
|
<input type="number" step="any" value={form.warning_threshold} onChange={(e) => setForm({ ...form, warning_threshold: e.target.value })} placeholder="e.g. 20" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Alert Threshold</label>
|
||||||
|
<input type="number" step="any" value={form.alert_threshold} onChange={(e) => setForm({ ...form, alert_threshold: e.target.value })} placeholder="e.g. 50" required />
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>SQL Query (must return one numeric value)</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.sql_text}
|
||||||
|
onChange={(e) => setForm({ ...form, sql_text: e.target.value })}
|
||||||
|
placeholder="SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="alert-form-actions field-full">
|
||||||
|
<button type="button" className="secondary-btn" onClick={testDefinition} disabled={testing}>
|
||||||
|
{testing ? "Testing..." : "Test query output"}
|
||||||
|
</button>
|
||||||
|
<button className="primary-btn" disabled={saving}>
|
||||||
|
{saving ? "Creating..." : "Create custom alert"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testResult && <div className="test-connection-result">{testResult}</div>}
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Custom Alert Definitions</h3>
|
||||||
|
<p>All saved SQL-based alert rules.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
{definitions.length ? (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>Comparison</th>
|
||||||
|
<th>Warn</th>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{definitions.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>{d.name}</td>
|
||||||
|
<td>{d.target_id ? targets.find((t) => t.id === d.target_id)?.name || `Target #${d.target_id}` : "All targets"}</td>
|
||||||
|
<td>{d.comparison}</td>
|
||||||
|
<td>{d.warning_threshold ?? "-"}</td>
|
||||||
|
<td>{d.alert_threshold}</td>
|
||||||
|
<td>{d.enabled ? "Enabled" : "Disabled"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" className={`table-action-btn toggle ${d.enabled ? "enabled" : "disabled"}`} onClick={() => toggleDefinition(d)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M7 12h10M12 7v10" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{d.enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="table-action-btn delete" onClick={() => removeDefinition(d.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No custom alerts created yet.</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,20 @@ import { Link } from "react-router-dom";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
function getTargetGroupMeta(target) {
|
||||||
|
const tags = target?.tags || {};
|
||||||
|
if (tags.monitor_mode !== "all_databases" || !tags.monitor_group_id) return null;
|
||||||
|
return {
|
||||||
|
id: tags.monitor_group_id,
|
||||||
|
name: tags.monitor_group_name || target.name || "All databases",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { tokens, refresh } = useAuth();
|
const { tokens, refresh, alertStatus } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [openGroups, setOpenGroups] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -13,8 +24,10 @@ export function DashboardPage() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch("/targets", {}, tokens, refresh);
|
const targetRows = await apiFetch("/targets", {}, tokens, refresh);
|
||||||
if (active) setTargets(data);
|
if (active) {
|
||||||
|
setTargets(targetRows);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -26,48 +39,172 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
}, [tokens, refresh]);
|
}, [tokens, refresh]);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Dashboard...</div>;
|
if (loading) return <div className="card">Loading dashboard...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
|
const targetSeverities = new Map();
|
||||||
|
for (const item of alertStatus.warnings || []) {
|
||||||
|
if (!targetSeverities.has(item.target_id)) targetSeverities.set(item.target_id, "warning");
|
||||||
|
}
|
||||||
|
for (const item of alertStatus.alerts || []) {
|
||||||
|
targetSeverities.set(item.target_id, "alert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedTargetCount = targetSeverities.size;
|
||||||
|
const okCount = Math.max(0, targets.length - affectedTargetCount);
|
||||||
|
const filteredTargets = targets.filter((t) => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
<div>
|
(t.name || "").toLowerCase().includes(q) ||
|
||||||
|
(t.host || "").toLowerCase().includes(q) ||
|
||||||
|
(t.dbname || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const groupedRows = [];
|
||||||
|
const groupedMap = new Map();
|
||||||
|
for (const t of filteredTargets) {
|
||||||
|
const meta = getTargetGroupMeta(t);
|
||||||
|
if (!meta) {
|
||||||
|
groupedRows.push({ type: "single", target: t });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groupedMap.has(meta.id)) {
|
||||||
|
const groupRow = { type: "group", groupId: meta.id, groupName: meta.name, targets: [] };
|
||||||
|
groupedMap.set(meta.id, groupRow);
|
||||||
|
groupedRows.push(groupRow);
|
||||||
|
}
|
||||||
|
groupedMap.get(meta.id).targets.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-page">
|
||||||
<h2>Dashboard Overview</h2>
|
<h2>Dashboard Overview</h2>
|
||||||
<div className="grid three">
|
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
|
||||||
<div className="card stat">
|
<div className="dashboard-kpis-grid">
|
||||||
|
<div className="card stat kpi-card">
|
||||||
|
<div className="kpi-orb blue" />
|
||||||
<strong>{targets.length}</strong>
|
<strong>{targets.length}</strong>
|
||||||
<span>Targets</span>
|
<span className="kpi-label">Total Targets</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat">
|
<div className="card stat kpi-card ok">
|
||||||
<strong>{targets.length}</strong>
|
<div className="kpi-orb green" />
|
||||||
<span>Status OK (placeholder)</span>
|
<strong>{okCount}</strong>
|
||||||
|
<span className="kpi-label">Status OK</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat">
|
<div className="card stat kpi-card warning">
|
||||||
<strong>0</strong>
|
<div className="kpi-orb amber" />
|
||||||
<span>Alerts (placeholder)</span>
|
<strong>{alertStatus.warning_count || 0}</strong>
|
||||||
|
<span className="kpi-label">Warnings</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat kpi-card alert">
|
||||||
|
<div className="kpi-orb red" />
|
||||||
|
<strong>{alertStatus.alert_count || 0}</strong>
|
||||||
|
<span className="kpi-label">Alerts</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
|
||||||
|
<div className="card dashboard-targets-card">
|
||||||
|
<div className="dashboard-targets-head">
|
||||||
|
<div>
|
||||||
<h3>Targets</h3>
|
<h3>Targets</h3>
|
||||||
<table>
|
<span>{filteredTargets.length} shown of {targets.length} registered</span>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<div className="dashboard-target-search">
|
||||||
<th>Name</th>
|
<input
|
||||||
<th>Host</th>
|
value={search}
|
||||||
<th>DB</th>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<th>Aktion</th>
|
placeholder="Search by name, host, or database..."
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
{targets.map((t) => (
|
|
||||||
<tr key={t.id}>
|
<div className="dashboard-target-list">
|
||||||
<td>{t.name}</td>
|
{groupedRows.map((row) => {
|
||||||
<td>{t.host}:{t.port}</td>
|
if (row.type === "single") {
|
||||||
<td>{t.dbname}</td>
|
const t = row.target;
|
||||||
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
|
const severity = targetSeverities.get(t.id) || "ok";
|
||||||
</tr>
|
return (
|
||||||
))}
|
<article className="dashboard-target-card" key={`single-${t.id}`}>
|
||||||
</tbody>
|
<div className="target-main">
|
||||||
</table>
|
<div className="target-title-row">
|
||||||
|
<h4>{t.name}</h4>
|
||||||
|
<span className={`status-chip ${severity}`}>
|
||||||
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Host:</strong> {t.host}:{t.port}</p>
|
||||||
|
<p><strong>DB:</strong> {t.dbname}</p>
|
||||||
|
</div>
|
||||||
|
<div className="target-actions">
|
||||||
|
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highestSeverity = row.targets.some((t) => targetSeverities.get(t.id) === "alert")
|
||||||
|
? "alert"
|
||||||
|
: row.targets.some((t) => targetSeverities.get(t.id) === "warning")
|
||||||
|
? "warning"
|
||||||
|
: "ok";
|
||||||
|
const first = row.targets[0];
|
||||||
|
const isOpen = !!openGroups[row.groupId];
|
||||||
|
return (
|
||||||
|
<article className="dashboard-target-card dashboard-target-group" key={`group-${row.groupId}`}>
|
||||||
|
<div className="target-main">
|
||||||
|
<div className="target-title-row">
|
||||||
|
<h4>{row.groupName}</h4>
|
||||||
|
<span className={`status-chip ${highestSeverity}`}>
|
||||||
|
{highestSeverity === "alert" ? "Alert" : highestSeverity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Host:</strong> {first.host}:{first.port}</p>
|
||||||
|
<p><strong>DB:</strong> All databases ({row.targets.length})</p>
|
||||||
|
</div>
|
||||||
|
<div className="target-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="table-action-btn edit"
|
||||||
|
onClick={() => setOpenGroups((prev) => ({ ...prev, [row.groupId]: !prev[row.groupId] }))}
|
||||||
|
>
|
||||||
|
{isOpen ? "Hide DBs" : "Show DBs"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="dashboard-group-list">
|
||||||
|
{row.targets.map((t) => {
|
||||||
|
const severity = targetSeverities.get(t.id) || "ok";
|
||||||
|
return (
|
||||||
|
<div key={`child-${t.id}`} className="dashboard-group-item">
|
||||||
|
<div>
|
||||||
|
<strong>{t.dbname}</strong>
|
||||||
|
<span className={`status-chip ${severity}`}>
|
||||||
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link className="table-action-btn details small-btn" to={`/targets/${t.id}`}>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredTargets.length === 0 && (
|
||||||
|
<div className="dashboard-empty">No targets match your search.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function LoginPage() {
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Login fehlgeschlagen");
|
setError("Login failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,16 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="card login-card" onSubmit={submit}>
|
<form className="card login-card" onSubmit={submit}>
|
||||||
|
<div className="login-logo-wrap" aria-hidden="true">
|
||||||
|
<img src="/nexapg-logo.svg" alt="NexaPG" className="login-logo" />
|
||||||
|
</div>
|
||||||
<div className="login-eyebrow">NexaPG Monitor</div>
|
<div className="login-eyebrow">NexaPG Monitor</div>
|
||||||
<h2>Willkommen zurück</h2>
|
<h2>Welcome back</h2>
|
||||||
<p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
|
<p className="login-subtitle">Sign in to access monitoring and query insights.</p>
|
||||||
<div className="input-shell">
|
<div className="input-shell">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="E-Mail"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -49,7 +52,7 @@ export function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button className="login-cta" disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
<button className="login-cta" disabled={loading}>{loading ? "Please wait..." : "Sign in"}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,46 +1,165 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function scoreQuery(row) {
|
||||||
|
const mean = Number(row.mean_time || 0);
|
||||||
|
const calls = Number(row.calls || 0);
|
||||||
|
const total = Number(row.total_time || 0);
|
||||||
|
const rows = Number(row.rows || 0);
|
||||||
|
return mean * 1.4 + total * 0.9 + calls * 0.2 + Math.min(rows / 50, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyQuery(row) {
|
||||||
|
if ((row.mean_time || 0) > 100) return { label: "Very Slow", kind: "danger" };
|
||||||
|
if ((row.total_time || 0) > 250) return { label: "Heavy", kind: "warn" };
|
||||||
|
if ((row.calls || 0) > 500) return { label: "Frequent", kind: "info" };
|
||||||
|
return { label: "Normal", kind: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactSql(sql) {
|
||||||
|
if (!sql) return "-";
|
||||||
|
return sql.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryTips(row) {
|
||||||
|
if (!row) return [];
|
||||||
|
const tips = [];
|
||||||
|
const sql = (row.query_text || "").toLowerCase();
|
||||||
|
|
||||||
|
if ((row.mean_time || 0) > 100) {
|
||||||
|
tips.push("High latency per call: inspect execution plan with EXPLAIN (ANALYZE, BUFFERS).");
|
||||||
|
}
|
||||||
|
if ((row.total_time || 0) > 500) {
|
||||||
|
tips.push("High cumulative runtime: optimize this query first for fastest overall impact.");
|
||||||
|
}
|
||||||
|
if ((row.calls || 0) > 500) {
|
||||||
|
tips.push("Very frequent query: consider caching or reducing call frequency in application code.");
|
||||||
|
}
|
||||||
|
if ((row.rows || 0) > 100000) {
|
||||||
|
tips.push("Large row output: return fewer columns/rows or add pagination at query/API layer.");
|
||||||
|
}
|
||||||
|
if (/select\s+\*/.test(sql)) {
|
||||||
|
tips.push("Avoid SELECT *: request only required columns to reduce IO and transfer cost.");
|
||||||
|
}
|
||||||
|
if (/order\s+by/.test(sql) && !/limit\s+\d+/.test(sql)) {
|
||||||
|
tips.push("ORDER BY without LIMIT can be expensive: add LIMIT where possible.");
|
||||||
|
}
|
||||||
|
if (/where/.test(sql) && / from /.test(sql)) {
|
||||||
|
tips.push("Filter query detected: verify indexes exist on WHERE / JOIN columns.");
|
||||||
|
}
|
||||||
|
if (/like\s+'%/.test(sql)) {
|
||||||
|
tips.push("Leading wildcard LIKE can bypass indexes: consider trigram index (pg_trgm).");
|
||||||
|
}
|
||||||
|
if (tips.length === 0) {
|
||||||
|
tips.push("No obvious anti-pattern detected. Validate with EXPLAIN and index usage statistics.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tips.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
export function QueryInsightsPage() {
|
export function QueryInsightsPage() {
|
||||||
const { tokens, refresh } = useAuth();
|
const { tokens, refresh } = useAuth();
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
|
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const t = await apiFetch("/targets", {}, tokens, refresh);
|
const t = await apiFetch("/targets", {}, tokens, refresh);
|
||||||
setTargets(t);
|
const supported = t.filter((item) => item.use_pg_stat_statements !== false);
|
||||||
if (t.length > 0) setTargetId(String(t[0].id));
|
setTargets(supported);
|
||||||
|
if (supported.length > 0) setTargetId(String(supported[0].id));
|
||||||
|
else setTargetId("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetId) return;
|
if (!targetId) return;
|
||||||
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
|
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refreshRef.current);
|
||||||
|
if (!active) return;
|
||||||
setRows(data);
|
setRows(data);
|
||||||
|
setSelectedQuery((prev) => {
|
||||||
|
if (!prev) return data[0] || null;
|
||||||
|
const keep = data.find((row) => row.queryid === prev.queryid);
|
||||||
|
return keep || data[0] || null;
|
||||||
|
});
|
||||||
|
setPage((prev) => (prev === 1 ? prev : 1));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [targetId, tokens, refresh]);
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [targetId, tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
const dedupedByQueryId = [...rows].reduce((acc, row) => {
|
||||||
|
if (!row?.queryid) return acc;
|
||||||
|
if (!acc[row.queryid]) acc[row.queryid] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const uniqueRows = Object.values(dedupedByQueryId);
|
||||||
|
|
||||||
|
const sorted = [...uniqueRows].sort((a, b) => (b.total_time || 0) - (a.total_time || 0));
|
||||||
|
const byMean = [...uniqueRows].sort((a, b) => (b.mean_time || 0) - (a.mean_time || 0));
|
||||||
|
const byCalls = [...uniqueRows].sort((a, b) => (b.calls || 0) - (a.calls || 0));
|
||||||
|
const byRows = [...uniqueRows].sort((a, b) => (b.rows || 0) - (a.rows || 0));
|
||||||
|
const byPriority = [...uniqueRows].sort((a, b) => scoreQuery(b) - scoreQuery(a));
|
||||||
|
|
||||||
|
const filtered = byPriority.filter((r) => {
|
||||||
|
if (!search.trim()) return true;
|
||||||
|
return compactSql(r.query_text).toLowerCase().includes(search.toLowerCase());
|
||||||
|
});
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
const selectedTips = buildQueryTips(selectedQuery);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: "priority", title: "Optimization Priority", row: byPriority[0], subtitle: "Best first candidate to optimize" },
|
||||||
|
{ key: "total", title: "Longest Total Time", row: sorted[0], subtitle: "Biggest cumulative runtime impact" },
|
||||||
|
{ key: "mean", title: "Highest Mean Time", row: byMean[0], subtitle: "Slowest single-call latency" },
|
||||||
|
{ key: "calls", title: "Most Frequent", row: byCalls[0], subtitle: "Executed very often" },
|
||||||
|
{ key: "rows", title: "Most Rows Returned", row: byRows[0], subtitle: "Potentially heavy scans" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="query-insights-page">
|
||||||
<h2>Query Insights</h2>
|
<h2>Query Insights</h2>
|
||||||
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
|
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
|
||||||
{error && <div className="card error">{error}</div>}
|
{targets.length === 0 && !loading && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<label>Target </label>
|
No targets with enabled <code>pg_stat_statements</code> are available.
|
||||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
Enable it in <strong>Targets Management</strong> for a target to use Query Insights.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
<div className="card query-toolbar">
|
||||||
|
<div className="field">
|
||||||
|
<label>Target</label>
|
||||||
|
<select value={targetId} onChange={(e) => setTargetId(e.target.value)} disabled={!targets.length}>
|
||||||
{targets.map((t) => (
|
{targets.map((t) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={t.id} value={t.id}>
|
||||||
{t.name}
|
{t.name}
|
||||||
@@ -48,32 +167,141 @@ export function QueryInsightsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="field">
|
||||||
|
<label>Search Query Text</label>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. select * from users"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="card">Loading query insights...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid three">
|
||||||
|
{categories.map((item) => {
|
||||||
|
const r = item.row;
|
||||||
|
const state = r ? classifyQuery(r) : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card query-category"
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => r && setSelectedQuery(r)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<h4>{item.title}</h4>
|
||||||
|
<small>{item.subtitle}</small>
|
||||||
|
{r ? (
|
||||||
|
<>
|
||||||
|
<div className={`query-state ${state.kind}`}>{state.label}</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Total</span>
|
||||||
|
<strong>{Number(r.total_time || 0).toFixed(2)} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Mean</span>
|
||||||
|
<strong>{Number(r.mean_time || 0).toFixed(2)} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Calls</span>
|
||||||
|
<strong>{r.calls}</strong>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="card query-list">
|
||||||
|
<h3>Ranked Queries</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Priority</th>
|
||||||
<th>Calls</th>
|
<th>Calls</th>
|
||||||
<th>Total ms</th>
|
<th>Total ms</th>
|
||||||
<th>Mean ms</th>
|
<th>Mean ms</th>
|
||||||
<th>Rows</th>
|
<th>Rows</th>
|
||||||
<th>Query</th>
|
<th>Query Preview</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{paged.map((r, i) => {
|
||||||
<tr key={i}>
|
const state = classifyQuery(r);
|
||||||
<td>{new Date(r.ts).toLocaleString()}</td>
|
return (
|
||||||
|
<tr key={`${r.queryid}-${i}-${safePage}`} className={selectedQuery?.queryid === r.queryid ? "active-row" : ""}>
|
||||||
|
<td><span className={`query-state ${state.kind}`}>{state.label}</span></td>
|
||||||
<td>{r.calls}</td>
|
<td>{r.calls}</td>
|
||||||
<td>{r.total_time.toFixed(2)}</td>
|
<td>{Number(r.total_time || 0).toFixed(2)}</td>
|
||||||
<td>{r.mean_time.toFixed(2)}</td>
|
<td>{Number(r.mean_time || 0).toFixed(2)}</td>
|
||||||
<td>{r.rows}</td>
|
<td>{r.rows}</td>
|
||||||
<td className="query">{r.query_text || "-"}</td>
|
<td className="query">
|
||||||
|
<button className="table-link" onClick={() => setSelectedQuery(r)}>
|
||||||
|
{compactSql(r.query_text)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div className="pagination">
|
||||||
|
<span className="pagination-info">
|
||||||
|
Showing {paged.length} of {filtered.length} queries
|
||||||
|
</span>
|
||||||
|
<div className="pagination-actions">
|
||||||
|
<button type="button" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>Page {safePage} / {totalPages}</span>
|
||||||
|
<button type="button" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card query-detail">
|
||||||
|
<h3>Selected Query</h3>
|
||||||
|
{selectedQuery ? (
|
||||||
|
<>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Calls</span><strong>{selectedQuery.calls}</strong></div>
|
||||||
|
<div><span>Total Time</span><strong>{Number(selectedQuery.total_time || 0).toFixed(2)} ms</strong></div>
|
||||||
|
<div><span>Mean Time</span><strong>{Number(selectedQuery.mean_time || 0).toFixed(2)} ms</strong></div>
|
||||||
|
<div><span>Rows</span><strong>{selectedQuery.rows}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="sql-block">
|
||||||
|
<code>{selectedQuery.query_text || "-- no query text available --"}</code>
|
||||||
|
</div>
|
||||||
|
<div className="query-tips">
|
||||||
|
<h4>Optimization Suggestions</h4>
|
||||||
|
<ul>
|
||||||
|
{selectedTips.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="query-hint">
|
||||||
|
Tip: focus first on queries with high <strong>Total Time</strong> (overall impact) and high <strong>Mean Time</strong> (latency hotspots).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No query selected.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
frontend/src/pages/ServiceInfoPage.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const total = Math.max(0, Number(seconds || 0));
|
||||||
|
const d = Math.floor(total / 86400);
|
||||||
|
const h = Math.floor((total % 86400) / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceInfoPage() {
|
||||||
|
const { tokens, refresh, serviceInfo } = useAuth();
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setError("");
|
||||||
|
const data = await apiFetch("/service/info", {}, tokens, refresh);
|
||||||
|
setInfo(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch((e) => setError(String(e.message || e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serviceInfo) setInfo(serviceInfo);
|
||||||
|
}, [serviceInfo]);
|
||||||
|
|
||||||
|
const checkNow = async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
const result = await apiFetch("/service/info/check", { method: "POST" }, tokens, refresh);
|
||||||
|
await load();
|
||||||
|
if (result.last_check_error) {
|
||||||
|
setMessage(`Version check finished with warning: ${result.last_check_error}`);
|
||||||
|
} else if (result.update_available) {
|
||||||
|
setMessage(`Update available: ${result.latest_version}`);
|
||||||
|
} else {
|
||||||
|
setMessage("Version check completed. No update detected.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return <div className="card">Loading service information...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="service-page">
|
||||||
|
<h2>Service Information</h2>
|
||||||
|
<p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok service-msg">{message}</div>}
|
||||||
|
|
||||||
|
<div className={`card service-hero ${info.update_available ? "update" : "ok"}`}>
|
||||||
|
<div>
|
||||||
|
<strong className="service-hero-title">
|
||||||
|
{info.update_available ? `Update available: ${info.latest_version}` : "Service is up to date"}
|
||||||
|
</strong>
|
||||||
|
<p className="muted service-hero-sub">
|
||||||
|
Automatic release checks run every 30 seconds. Source: official NexaPG upstream releases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
|
||||||
|
Check Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid three">
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Application</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>App Name</span>
|
||||||
|
<strong>{info.app_name}</strong>
|
||||||
|
<span>Environment</span>
|
||||||
|
<strong>{info.environment}</strong>
|
||||||
|
<span>API Prefix</span>
|
||||||
|
<strong>{info.api_prefix}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Runtime</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Host</span>
|
||||||
|
<strong>{info.hostname}</strong>
|
||||||
|
<span>Python</span>
|
||||||
|
<strong>{info.python_version}</strong>
|
||||||
|
<span>Uptime</span>
|
||||||
|
<strong>{formatUptime(info.uptime_seconds)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Version Status</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Current NexaPG Version</span>
|
||||||
|
<strong>{info.app_version}</strong>
|
||||||
|
<span>Latest Known Version</span>
|
||||||
|
<strong>{info.latest_version || "-"}</strong>
|
||||||
|
<span>Update Status</span>
|
||||||
|
<strong className={info.update_available ? "service-status-update" : "service-status-ok"}>
|
||||||
|
{info.update_available ? "Update available" : "Up to date"}
|
||||||
|
</strong>
|
||||||
|
<span>Last Check</span>
|
||||||
|
<strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Release Source</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed
|
||||||
|
via UI.
|
||||||
|
</p>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Source Repository</span>
|
||||||
|
<strong>{info.update_source}</strong>
|
||||||
|
<span>Latest Reference Type</span>
|
||||||
|
<strong>{info.latest_ref || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Version Control Policy</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG
|
||||||
|
repository can change that behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
@@ -41,6 +41,19 @@ function formatNumber(value, digits = 2) {
|
|||||||
return Number(value).toFixed(digits);
|
return Number(value).toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHostMetricUnavailable() {
|
||||||
|
return "N/A (agentless)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDiskSpaceAgentless(diskSpace) {
|
||||||
|
if (!diskSpace) return formatHostMetricUnavailable();
|
||||||
|
if (diskSpace.free_bytes !== null && diskSpace.free_bytes !== undefined) {
|
||||||
|
return formatBytes(diskSpace.free_bytes);
|
||||||
|
}
|
||||||
|
if (diskSpace.status === "unavailable") return formatHostMetricUnavailable();
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
function MetricsTooltip({ active, payload, label }) {
|
function MetricsTooltip({ active, payload, label }) {
|
||||||
if (!active || !payload || payload.length === 0) return null;
|
if (!active || !payload || payload.length === 0) return null;
|
||||||
const row = payload[0]?.payload || {};
|
const row = payload[0]?.payload || {};
|
||||||
@@ -54,6 +67,19 @@ function MetricsTooltip({ active, payload, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function didMetricSeriesChange(prev = [], next = []) {
|
||||||
|
if (!Array.isArray(prev) || !Array.isArray(next)) return true;
|
||||||
|
if (prev.length !== next.length) return true;
|
||||||
|
if (prev.length === 0 && next.length === 0) return false;
|
||||||
|
const prevLast = prev[prev.length - 1];
|
||||||
|
const nextLast = next[next.length - 1];
|
||||||
|
return prevLast?.ts !== nextLast?.ts || Number(prevLast?.value) !== Number(nextLast?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetUnreachableError(err) {
|
||||||
|
return err?.code === "target_unreachable" || err?.status === 503;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
||||||
const { from, to } = toQueryRange(range);
|
const { from, to } = toQueryRange(range);
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
@@ -66,47 +92,125 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
|
|||||||
|
|
||||||
export function TargetDetailPage() {
|
export function TargetDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { tokens, refresh } = useAuth();
|
const navigate = useNavigate();
|
||||||
|
const { tokens, refresh, uiMode } = useAuth();
|
||||||
const [range, setRange] = useState("1h");
|
const [range, setRange] = useState("1h");
|
||||||
|
const [liveMode, setLiveMode] = useState(false);
|
||||||
const [series, setSeries] = useState({});
|
const [series, setSeries] = useState({});
|
||||||
const [locks, setLocks] = useState([]);
|
const [locks, setLocks] = useState([]);
|
||||||
const [activity, setActivity] = useState([]);
|
const [activity, setActivity] = useState([]);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
const [targetMeta, setTargetMeta] = useState(null);
|
const [targetMeta, setTargetMeta] = useState(null);
|
||||||
|
const [owners, setOwners] = useState([]);
|
||||||
|
const [groupTargets, setGroupTargets] = useState([]);
|
||||||
|
const [offlineState, setOfflineState] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
const loadAll = async () => {
|
||||||
|
if (!series.connections?.length) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
|
const [connections, xacts, cache, targetInfo, ownerRows, allTargets] = await Promise.all([
|
||||||
loadMetric(id, "connections_total", range, tokens, refresh),
|
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
|
||||||
loadMetric(id, "xacts_total", range, tokens, refresh),
|
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
|
||||||
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
|
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
|
apiFetch("/targets", {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}`, {}, tokens, refresh),
|
|
||||||
]);
|
]);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setSeries({ connections, xacts, cache });
|
setSeries({ connections, xacts, cache });
|
||||||
|
setTargetMeta(targetInfo);
|
||||||
|
setOwners(ownerRows);
|
||||||
|
const groupId = targetInfo?.tags?.monitor_group_id;
|
||||||
|
if (groupId) {
|
||||||
|
const sameGroup = allTargets
|
||||||
|
.filter((item) => item?.tags?.monitor_group_id === groupId)
|
||||||
|
.sort((a, b) => (a.dbname || "").localeCompare(b.dbname || ""));
|
||||||
|
setGroupTargets(sameGroup);
|
||||||
|
} else {
|
||||||
|
setGroupTargets([]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [locksTable, activityTable, overviewData] = await Promise.all([
|
||||||
|
apiFetch(`/targets/${id}/locks`, {}, tokens, refreshRef.current),
|
||||||
|
apiFetch(`/targets/${id}/activity`, {}, tokens, refreshRef.current),
|
||||||
|
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
setLocks(locksTable);
|
setLocks(locksTable);
|
||||||
setActivity(activityTable);
|
setActivity(activityTable);
|
||||||
setOverview(overviewData);
|
setOverview(overviewData);
|
||||||
setTargetMeta(targetInfo);
|
setOfflineState(null);
|
||||||
|
} catch (liveErr) {
|
||||||
|
if (!active) return;
|
||||||
|
if (isTargetUnreachableError(liveErr)) {
|
||||||
|
setLocks([]);
|
||||||
|
setActivity([]);
|
||||||
|
setOverview(null);
|
||||||
|
setOfflineState({
|
||||||
|
message:
|
||||||
|
"Target is currently unreachable. Check host/port, network route, SSL mode, and database availability.",
|
||||||
|
host: liveErr?.details?.host || targetInfo?.host || "-",
|
||||||
|
port: liveErr?.details?.port || targetInfo?.port || "-",
|
||||||
|
requestId: liveErr?.requestId || null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw liveErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false);
|
if (active) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
loadAll();
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [id, range, tokens, refresh]);
|
}, [id, range, tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveMode) return;
|
||||||
|
let active = true;
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [connections, xacts, cache] = await Promise.all([
|
||||||
|
loadMetric(id, "connections_total", "15m", tokens, refreshRef.current),
|
||||||
|
loadMetric(id, "xacts_total", "15m", tokens, refreshRef.current),
|
||||||
|
loadMetric(id, "cache_hit_ratio", "15m", tokens, refreshRef.current),
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
|
const nextSeries = { connections, xacts, cache };
|
||||||
|
setSeries((prev) => {
|
||||||
|
const changed =
|
||||||
|
didMetricSeriesChange(prev.connections, nextSeries.connections) ||
|
||||||
|
didMetricSeriesChange(prev.xacts, nextSeries.xacts) ||
|
||||||
|
didMetricSeriesChange(prev.cache, nextSeries.cache);
|
||||||
|
return changed ? nextSeries : prev;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep previous chart values if a live tick fails.
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [liveMode, id, tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@@ -135,7 +239,39 @@ export function TargetDetailPage() {
|
|||||||
[series]
|
[series]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Target Detail...</div>;
|
const easySummary = useMemo(() => {
|
||||||
|
if (!overview) return null;
|
||||||
|
const latest = chartData[chartData.length - 1];
|
||||||
|
const issues = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
if (overview.partial_failures?.length > 0) warnings.push("Some advanced metrics are currently unavailable.");
|
||||||
|
if ((overview.performance?.deadlocks || 0) > 0) issues.push("Deadlocks were detected.");
|
||||||
|
if ((overview.replication?.mode === "standby") && (overview.replication?.replay_lag_seconds || 0) > 5) {
|
||||||
|
issues.push("Replication lag is above 5 seconds.");
|
||||||
|
}
|
||||||
|
if ((latest?.cache || 0) > 0 && latest.cache < 90) warnings.push("Cache hit ratio is below 90%.");
|
||||||
|
if ((latest?.connections || 0) > 120) warnings.push("Connection count is relatively high.");
|
||||||
|
if ((locks?.length || 0) > 150) warnings.push("High number of active locks.");
|
||||||
|
|
||||||
|
const health = issues.length > 0 ? "problem" : warnings.length > 0 ? "warning" : "ok";
|
||||||
|
const message =
|
||||||
|
health === "ok"
|
||||||
|
? "Everything looks healthy. No major risks detected right now."
|
||||||
|
: health === "warning"
|
||||||
|
? "System is operational, but there are signals you should watch."
|
||||||
|
: "Attention required. Critical signals need investigation.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
message,
|
||||||
|
issues,
|
||||||
|
warnings,
|
||||||
|
latest,
|
||||||
|
};
|
||||||
|
}, [overview, chartData, locks]);
|
||||||
|
|
||||||
|
if (loading) return <div className="card">Loading target detail...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
const role = overview?.instance?.role || "-";
|
const role = overview?.instance?.role || "-";
|
||||||
@@ -148,16 +284,129 @@ export function TargetDetailPage() {
|
|||||||
Target Detail {targetMeta?.name || `#${id}`}
|
Target Detail {targetMeta?.name || `#${id}`}
|
||||||
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||||
</h2>
|
</h2>
|
||||||
{overview && (
|
{groupTargets.length > 1 && (
|
||||||
|
<div className="field target-db-switcher">
|
||||||
|
<label>Database in this target group</label>
|
||||||
|
<select
|
||||||
|
value={String(id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const targetId = e.target.value;
|
||||||
|
if (targetId && String(targetId) !== String(id)) {
|
||||||
|
navigate(`/targets/${targetId}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupTargets.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.dbname} ({item.name})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="owner-row">
|
||||||
|
<span className="muted">Responsible users:</span>
|
||||||
|
{owners.length > 0 ? owners.map((item) => <span key={item.user_id} className="owner-pill">{item.email}</span>) : <span className="muted">none assigned</span>}
|
||||||
|
</div>
|
||||||
|
{offlineState && (
|
||||||
|
<div className="card target-offline-card">
|
||||||
|
<h3>Target Offline</h3>
|
||||||
|
<p>{offlineState.message}</p>
|
||||||
|
<div className="target-offline-meta">
|
||||||
|
<span><strong>Host:</strong> {offlineState.host}</span>
|
||||||
|
<span><strong>Port:</strong> {offlineState.port}</span>
|
||||||
|
{offlineState.requestId ? <span><strong>Request ID:</strong> {offlineState.requestId}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uiMode === "easy" && overview && easySummary && (
|
||||||
|
<>
|
||||||
|
<div className={`card easy-status ${easySummary.health}`}>
|
||||||
|
<h3>System Health</h3>
|
||||||
|
<p>{easySummary.message}</p>
|
||||||
|
<div className="easy-badge-row">
|
||||||
|
<span className={`easy-badge ${easySummary.health}`}>
|
||||||
|
{easySummary.health === "ok" ? "OK" : easySummary.health === "warning" ? "Warning" : "Problem"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid three">
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.connections, 0)}</strong>
|
||||||
|
<span>Current Connections</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.tps, 2)}</strong>
|
||||||
|
<span>Transactions/sec (approx)</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.cache, 2)}%</strong>
|
||||||
|
<span>Cache Hit Ratio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Quick Explanation</h3>
|
||||||
|
{easySummary.issues.length === 0 && easySummary.warnings.length === 0 && (
|
||||||
|
<p>No immediate problems were detected. Keep monitoring over time.</p>
|
||||||
|
)}
|
||||||
|
{easySummary.issues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<strong>Problems</strong>
|
||||||
|
<ul className="easy-list">
|
||||||
|
{easySummary.issues.map((item, idx) => <li key={`i-${idx}`}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{easySummary.warnings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<strong>Things to watch</strong>
|
||||||
|
<ul className="easy-list">
|
||||||
|
{easySummary.warnings.map((item, idx) => <li key={`w-${idx}`}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="card">
|
||||||
|
<h3>Instance Summary</h3>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Role</span><strong>{overview.instance.role}</strong></div>
|
||||||
|
<div><span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong></div>
|
||||||
|
<div><span>Target Port</span><strong>{targetMeta?.port ?? "-"}</strong></div>
|
||||||
|
<div><span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong></div>
|
||||||
|
<div><span>Replication Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
||||||
|
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Activity Snapshot</h3>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Running Sessions</span><strong>{activity.filter((a) => a.state === "active").length}</strong></div>
|
||||||
|
<div><span>Total Sessions</span><strong>{activity.length}</strong></div>
|
||||||
|
<div><span>Current Locks</span><strong>{locks.length}</strong></div>
|
||||||
|
<div><span>Deadlocks</span><strong>{overview.performance.deadlocks ?? 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uiMode === "dba" && overview && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Database Overview</h3>
|
<h3>Database Overview</h3>
|
||||||
|
<p className="muted" style={{ marginTop: 2 }}>
|
||||||
|
Agentless mode: host-level CPU, RAM, and free-disk metrics are not available.
|
||||||
|
</p>
|
||||||
<div className="grid three overview-kv">
|
<div className="grid three overview-kv">
|
||||||
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
|
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
|
||||||
<div>
|
<div>
|
||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeit seit Start des Postgres-Prozesses">
|
<div title="Time since PostgreSQL postmaster start">
|
||||||
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
||||||
@@ -165,16 +414,16 @@ export function TargetDetailPage() {
|
|||||||
<span>Target Port</span>
|
<span>Target Port</span>
|
||||||
<strong>{targetMeta?.port ?? "-"}</strong>
|
<strong>{targetMeta?.port ?? "-"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Groesse der aktuell verbundenen Datenbank">
|
<div title="Current database total size">
|
||||||
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Gesamtgroesse der WAL-Dateien (falls verfuegbar)">
|
<div title="Total WAL directory size (when available)">
|
||||||
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Optional ueber Agent/SSH ermittelbar">
|
<div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
|
||||||
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
<span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeitliche Replikationsverzoegerung auf Standby">
|
<div title="Replication replay delay on standby">
|
||||||
<span>Replay Lag</span>
|
<span>Replay Lag</span>
|
||||||
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
||||||
{formatSeconds(overview.replication.replay_lag_seconds)}
|
{formatSeconds(overview.replication.replay_lag_seconds)}
|
||||||
@@ -183,6 +432,12 @@ export function TargetDetailPage() {
|
|||||||
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
|
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
|
||||||
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
||||||
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
||||||
|
<div title="Host CPU requires OS-level telemetry">
|
||||||
|
<span>Host CPU</span><strong>{formatHostMetricUnavailable()}</strong>
|
||||||
|
</div>
|
||||||
|
<div title="Host RAM requires OS-level telemetry">
|
||||||
|
<span>Host RAM</span><strong>{formatHostMetricUnavailable()}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid two">
|
<div className="grid two">
|
||||||
@@ -255,23 +510,43 @@ export function TargetDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="range-picker">
|
<div className="range-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`live-btn ${liveMode ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setLiveMode((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (next) setRange("15m");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</button>
|
||||||
{Object.keys(ranges).map((r) => (
|
{Object.keys(ranges).map((r) => (
|
||||||
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => {
|
||||||
|
setLiveMode(false);
|
||||||
|
setRange(r);
|
||||||
|
}}
|
||||||
|
className={r === range ? "active" : ""}
|
||||||
|
>
|
||||||
{r}
|
{r}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ height: 320 }}>
|
<div className="card" style={{ height: 320 }}>
|
||||||
<h3>Connections / TPS approx / Cache hit ratio</h3>
|
<h3>Connections / TPS (approx) / Cache Hit Ratio</h3>
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="ts" hide />
|
<XAxis dataKey="ts" hide />
|
||||||
<YAxis yAxisId="left" />
|
<YAxis yAxisId="left" />
|
||||||
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
||||||
<Tooltip content={<MetricsTooltip />} />
|
<Tooltip content={<MetricsTooltip />} />
|
||||||
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} />
|
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} />
|
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} />
|
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
|
name: "",
|
||||||
|
host: "",
|
||||||
|
port: 5432,
|
||||||
|
dbname: "postgres",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
sslmode: "prefer",
|
||||||
|
use_pg_stat_statements: true,
|
||||||
|
discover_all_databases: false,
|
||||||
|
owner_user_ids: [],
|
||||||
|
tags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyEditForm = {
|
||||||
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
host: "",
|
host: "",
|
||||||
port: 5432,
|
port: 5432,
|
||||||
@@ -11,22 +26,120 @@ const emptyForm = {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
sslmode: "prefer",
|
sslmode: "prefer",
|
||||||
tags: {},
|
use_pg_stat_statements: true,
|
||||||
|
owner_user_ids: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toggleOwner(ids, userId) {
|
||||||
|
return ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
|
||||||
|
const pickerRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const filtered = candidates.filter((item) =>
|
||||||
|
item.email.toLowerCase().includes(query.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event) => {
|
||||||
|
if (!pickerRef.current?.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="owner-picker" ref={pickerRef}>
|
||||||
|
<div className="owner-selected">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
selected.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`selected-${item.user_id}`}
|
||||||
|
type="button"
|
||||||
|
className="owner-selected-chip"
|
||||||
|
onClick={() => onToggle(item.user_id)}
|
||||||
|
title="Remove owner"
|
||||||
|
>
|
||||||
|
<span>{item.email}</span>
|
||||||
|
<span aria-hidden="true">x</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="muted">No owners selected yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`owner-search-shell ${open ? "open" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="owner-search-input"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
placeholder="Search users by email..."
|
||||||
|
/>
|
||||||
|
<button type="button" className="owner-search-toggle" onClick={() => setOpen((prev) => !prev)}>
|
||||||
|
v
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="owner-dropdown">
|
||||||
|
<div className="owner-search-results">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const active = selectedIds.includes(item.user_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.user_id}
|
||||||
|
type="button"
|
||||||
|
className={`owner-result ${active ? "active" : ""}`}
|
||||||
|
onClick={() => onToggle(item.user_id)}
|
||||||
|
>
|
||||||
|
<span>{item.email}</span>
|
||||||
|
<small>{item.role}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TargetsPage() {
|
export function TargetsPage() {
|
||||||
const { tokens, refresh, me } = useAuth();
|
const { tokens, refresh, me } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
|
||||||
|
const [saveState, setSaveState] = useState({ loading: false, message: "" });
|
||||||
|
const [ownerCandidates, setOwnerCandidates] = useState([]);
|
||||||
|
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
|
||||||
|
const [editOwnerQuery, setEditOwnerQuery] = useState("");
|
||||||
|
|
||||||
const canManage = me?.role === "admin" || me?.role === "operator";
|
const canManage = me?.role === "admin" || me?.role === "operator";
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (canManage) {
|
||||||
|
const [targetRows, candidates] = await Promise.all([
|
||||||
|
apiFetch("/targets", {}, tokens, refresh),
|
||||||
|
apiFetch("/targets/owner-candidates", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setTargets(targetRows);
|
||||||
|
setOwnerCandidates(candidates);
|
||||||
|
} else {
|
||||||
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
@@ -37,7 +150,7 @@ export function TargetsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, [canManage]);
|
||||||
|
|
||||||
const createTarget = async (e) => {
|
const createTarget = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -50,8 +163,33 @@ export function TargetsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
setTestState({ loading: true, message: "", ok: null });
|
||||||
|
try {
|
||||||
|
const result = await apiFetch(
|
||||||
|
"/targets/test-connection",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
host: form.host,
|
||||||
|
port: form.port,
|
||||||
|
dbname: form.dbname,
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
sslmode: form.sslmode,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setTestState({ loading: false, message: `${result.message} (PostgreSQL ${result.server_version})`, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
setTestState({ loading: false, message: String(e.message || e), ok: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTarget = async (id) => {
|
const deleteTarget = async (id) => {
|
||||||
if (!confirm("Target löschen?")) return;
|
if (!confirm("Delete target?")) return;
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
||||||
await load();
|
await load();
|
||||||
@@ -60,41 +198,105 @@ export function TargetsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEdit = (target) => {
|
||||||
|
setEditing(true);
|
||||||
|
setSaveState({ loading: false, message: "" });
|
||||||
|
setEditOwnerQuery("");
|
||||||
|
setEditForm({
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
host: target.host,
|
||||||
|
port: target.port,
|
||||||
|
dbname: target.dbname,
|
||||||
|
username: target.username,
|
||||||
|
password: "",
|
||||||
|
sslmode: target.sslmode,
|
||||||
|
use_pg_stat_statements: target.use_pg_stat_statements !== false,
|
||||||
|
owner_user_ids: Array.isArray(target.owner_user_ids) ? target.owner_user_ids : [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setEditForm(emptyEditForm);
|
||||||
|
setSaveState({ loading: false, message: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editForm.id) return;
|
||||||
|
setSaveState({ loading: true, message: "" });
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: editForm.name,
|
||||||
|
host: editForm.host,
|
||||||
|
port: Number(editForm.port),
|
||||||
|
dbname: editForm.dbname,
|
||||||
|
username: editForm.username,
|
||||||
|
sslmode: editForm.sslmode,
|
||||||
|
use_pg_stat_statements: !!editForm.use_pg_stat_statements,
|
||||||
|
owner_user_ids: editForm.owner_user_ids || [],
|
||||||
|
};
|
||||||
|
if (editForm.password.trim()) payload.password = editForm.password;
|
||||||
|
|
||||||
|
await apiFetch(`/targets/${editForm.id}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
setSaveState({ loading: false, message: "Target updated." });
|
||||||
|
setEditing(false);
|
||||||
|
setEditForm(emptyEditForm);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setSaveState({ loading: false, message: String(e.message || e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="targets-page">
|
||||||
<h2>Targets Management</h2>
|
<h2>Targets Management</h2>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<form className="card grid two" onSubmit={createTarget}>
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>New Target</h3>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<form className="target-form grid two" onSubmit={createTarget}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
<small>Eindeutiger Anzeigename im Dashboard.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Host</label>
|
<label>Host</label>
|
||||||
<input placeholder="z.B. 172.16.0.106 oder db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
||||||
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Port</label>
|
<label>Port</label>
|
||||||
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
||||||
<small>Standard PostgreSQL Port ist 5432 (oder gemappter Host-Port).</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>DB Name</label>
|
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
|
||||||
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
<input
|
||||||
<small>Name der Datenbank, die überwacht werden soll.</small>
|
placeholder={form.discover_all_databases ? "e.g. postgres" : "e.g. postgres or appdb"}
|
||||||
|
value={form.dbname}
|
||||||
|
onChange={(e) => setForm({ ...form, dbname: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{form.discover_all_databases
|
||||||
|
? "Connection database used to crawl all available databases on this instance."
|
||||||
|
: "Single database to monitor for this target."}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||||
<small>DB User mit Leserechten auf Stats-Views.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||||
<small>Wird verschlüsselt in der Core-DB gespeichert.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>SSL Mode</label>
|
<label>SSL Mode</label>
|
||||||
@@ -104,32 +306,162 @@ export function TargetsPage() {
|
|||||||
<option value="require">require</option>
|
<option value="require">require</option>
|
||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
|
If you see "rejected SSL upgrade", switch to <code>disable</code>.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field toggle-field">
|
||||||
<label> </label>
|
<label>Query Insights Source</label>
|
||||||
<button>Target anlegen</button>
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.use_pg_stat_statements}
|
||||||
|
onChange={(e) => setForm({ ...form, use_pg_stat_statements: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Use pg_stat_statements for this target</strong>
|
||||||
|
<small>Disable this if the extension is unavailable on the target.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field toggle-field">
|
||||||
|
<label>Scope</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.discover_all_databases}
|
||||||
|
onChange={(e) => setForm({ ...form, discover_all_databases: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Discover and add all databases</strong>
|
||||||
|
<small>Requires credentials with access to list databases (typically a superuser).</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>Responsible Users (Target Owners)</label>
|
||||||
|
<OwnerPicker
|
||||||
|
candidates={ownerCandidates}
|
||||||
|
selectedIds={form.owner_user_ids}
|
||||||
|
query={createOwnerQuery}
|
||||||
|
onQueryChange={setCreateOwnerQuery}
|
||||||
|
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
|
||||||
|
/>
|
||||||
|
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||||
|
</div>
|
||||||
|
<div className="field submit-field field-full">
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
|
||||||
|
{testState.loading ? "Testing..." : "Test connection"}
|
||||||
|
</button>
|
||||||
|
<button className="primary-btn">Create target</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{testState.message && (
|
||||||
|
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
</details>
|
||||||
<div className="card tips">
|
)}
|
||||||
<strong>Troubleshooting</strong>
|
|
||||||
<p>
|
{canManage && editing && (
|
||||||
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
|
<section className="card">
|
||||||
</p>
|
<h3>Edit Target</h3>
|
||||||
<p>
|
<form className="target-form grid two" onSubmit={saveEdit}>
|
||||||
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
|
<div className="field">
|
||||||
</p>
|
<label>Name</label>
|
||||||
<p>
|
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
|
||||||
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Host</label>
|
||||||
|
<input value={editForm.host} onChange={(e) => setEditForm({ ...editForm, host: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: Number(e.target.value) })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>DB Name</label>
|
||||||
|
<input value={editForm.dbname} onChange={(e) => setEditForm({ ...editForm, dbname: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<input value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>New Password (optional)</label>
|
||||||
|
<input type="password" placeholder="Leave empty to keep current" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>SSL Mode</label>
|
||||||
|
<select value={editForm.sslmode} onChange={(e) => setEditForm({ ...editForm, sslmode: e.target.value })}>
|
||||||
|
<option value="disable">disable</option>
|
||||||
|
<option value="prefer">prefer</option>
|
||||||
|
<option value="require">require</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field toggle-field">
|
||||||
|
<label>Query Insights Source</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!editForm.use_pg_stat_statements}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, use_pg_stat_statements: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Use pg_stat_statements for this target</strong>
|
||||||
|
<small>Disable this if the extension is unavailable on the target.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>Responsible Users (Target Owners)</label>
|
||||||
|
<OwnerPicker
|
||||||
|
candidates={ownerCandidates}
|
||||||
|
selectedIds={editForm.owner_user_ids}
|
||||||
|
query={editOwnerQuery}
|
||||||
|
onQueryChange={setEditOwnerQuery}
|
||||||
|
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
|
||||||
|
/>
|
||||||
|
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||||
|
</div>
|
||||||
|
<div className="field submit-field field-full">
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="secondary-btn" onClick={cancelEdit}>Cancel</button>
|
||||||
|
<button className="primary-btn" disabled={saveState.loading}>{saveState.loading ? "Saving..." : "Save changes"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{saveState.message && <div className="test-connection-result">{saveState.message}</div>}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
<div className="card">
|
|
||||||
|
{canManage && (
|
||||||
|
<details className="card collapsible tips">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Troubleshooting</h3>
|
||||||
|
<p>Quick checks for the most common connection issues.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<p>
|
||||||
|
<code>Connection refused</code>: host/port is wrong or database is unreachable.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>rejected SSL upgrade</code>: set SSL mode to <code>disable</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>localhost</code> points to the backend container itself, not your host machine.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card targets-table">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Lade Targets...</p>
|
<p>Loading targets...</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -137,7 +469,9 @@ export function TargetsPage() {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Host</th>
|
<th>Host</th>
|
||||||
<th>DB</th>
|
<th>DB</th>
|
||||||
<th>Aktionen</th>
|
<th>Owners</th>
|
||||||
|
<th>Query Insights</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -146,9 +480,43 @@ export function TargetsPage() {
|
|||||||
<td>{t.name}</td>
|
<td>{t.name}</td>
|
||||||
<td>{t.host}:{t.port}</td>
|
<td>{t.host}:{t.port}</td>
|
||||||
<td>{t.dbname}</td>
|
<td>{t.dbname}</td>
|
||||||
|
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
|
||||||
<td>
|
<td>
|
||||||
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
|
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
|
||||||
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
|
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
{canManage && (
|
||||||
|
<button className="table-action-btn edit" onClick={() => startEdit(t)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M3 17.25V21h3.75L19.81 7.94l-3.75-3.75L3 17.25zm2.92 2.33H5v-.92l10.06-10.06.92.92L5.92 19.58zM20.71 6.04a1 1 0 0 0 0-1.41L19.37 3.3a1 1 0 0 0-1.41 0l-1.13 1.12 3.75 3.75 1.13-1.13z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<button className="table-action-btn delete" onClick={() => deleteTarget(t.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
100
frontend/src/pages/UserSettingsPage.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
export function UserSettingsPage() {
|
||||||
|
const { tokens, refresh } = useAuth();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
current_password: "",
|
||||||
|
new_password: "",
|
||||||
|
confirm_password: "",
|
||||||
|
});
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage("");
|
||||||
|
setError("");
|
||||||
|
if (form.new_password.length < 8) {
|
||||||
|
setError("New password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.new_password !== form.confirm_password) {
|
||||||
|
setError("Password confirmation does not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
await apiFetch(
|
||||||
|
"/me/password",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: form.current_password,
|
||||||
|
new_password: form.new_password,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setForm({ current_password: "", new_password: "", confirm_password: "" });
|
||||||
|
setMessage("Password changed successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-settings-page">
|
||||||
|
<h2>User Settings</h2>
|
||||||
|
<p className="muted">Manage your personal account security settings.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok">{message}</div>}
|
||||||
|
|
||||||
|
<div className="card user-settings-card">
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form className="grid two" onSubmit={submit}>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Current password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.current_password}
|
||||||
|
onChange={(e) => setForm({ ...form, current_password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.new_password}
|
||||||
|
onChange={(e) => setForm({ ...form, new_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={(e) => setForm({ ...form, confirm_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Saving..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { API_URL } from "./api";
|
import { API_URL } from "./api";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
|
const UI_MODE_KEY = "nexapg_ui_mode";
|
||||||
|
|
||||||
function loadStorage() {
|
function loadStorage() {
|
||||||
try {
|
try {
|
||||||
@@ -11,10 +12,26 @@ function loadStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadUiMode() {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(UI_MODE_KEY);
|
||||||
|
if (value === "easy" || value === "dba") return value;
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return "dba";
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const initial = loadStorage();
|
const initial = loadStorage();
|
||||||
const [tokens, setTokens] = useState(initial?.tokens || null);
|
const [tokens, setTokens] = useState(initial?.tokens || null);
|
||||||
const [me, setMe] = useState(initial?.me || null);
|
const [me, setMe] = useState(initial?.me || null);
|
||||||
|
const [uiMode, setUiModeState] = useState(loadUiMode);
|
||||||
|
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
||||||
|
const [alertToasts, setAlertToasts] = useState([]);
|
||||||
|
const [serviceInfo, setServiceInfo] = useState(null);
|
||||||
|
const knownAlertKeysRef = useRef(new Set());
|
||||||
|
const hasAlertSnapshotRef = useRef(false);
|
||||||
|
|
||||||
const persist = (nextTokens, nextMe) => {
|
const persist = (nextTokens, nextMe) => {
|
||||||
if (nextTokens && nextMe) {
|
if (nextTokens && nextMe) {
|
||||||
@@ -78,7 +95,153 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
|
const dismissAlertToast = (toastId) => {
|
||||||
|
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
|
||||||
|
setTimeout(() => {
|
||||||
|
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
|
||||||
|
}, 220);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
||||||
|
setAlertToasts([]);
|
||||||
|
knownAlertKeysRef.current = new Set();
|
||||||
|
hasAlertSnapshotRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const pushToastsForNewItems = (items) => {
|
||||||
|
if (!items.length) return;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const nextToasts = items.slice(0, 4).map((item, idx) => ({
|
||||||
|
id: `${createdAt}-${idx}-${item.alert_key}`,
|
||||||
|
alertKey: item.alert_key,
|
||||||
|
severity: item.severity,
|
||||||
|
title: item.name,
|
||||||
|
target: item.target_name,
|
||||||
|
message: item.message,
|
||||||
|
closing: false,
|
||||||
|
}));
|
||||||
|
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
|
||||||
|
for (const toast of nextToasts) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
dismissAlertToast(toast.id);
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAlertStatus = async () => {
|
||||||
|
const request = async (accessToken) =>
|
||||||
|
fetch(`${API_URL}/alerts/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await request(tokens.accessToken);
|
||||||
|
if (res.status === 401 && tokens.refreshToken) {
|
||||||
|
const refreshed = await refresh();
|
||||||
|
if (refreshed?.accessToken) {
|
||||||
|
res = await request(refreshed.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!mounted) return;
|
||||||
|
setAlertStatus(payload);
|
||||||
|
|
||||||
|
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
|
||||||
|
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
|
||||||
|
if (!hasAlertSnapshotRef.current) {
|
||||||
|
knownAlertKeysRef.current = currentKeys;
|
||||||
|
hasAlertSnapshotRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
|
||||||
|
knownAlertKeysRef.current = currentKeys;
|
||||||
|
pushToastsForNewItems(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAlertStatus().catch(() => {});
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadAlertStatus().catch(() => {});
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
setServiceInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const request = async (path, method = "GET") => {
|
||||||
|
const doFetch = async (accessToken) =>
|
||||||
|
fetch(`${API_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await doFetch(tokens.accessToken);
|
||||||
|
if (res.status === 401 && tokens.refreshToken) {
|
||||||
|
const refreshed = await refresh();
|
||||||
|
if (refreshed?.accessToken) {
|
||||||
|
res = await doFetch(refreshed.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runServiceCheck = async () => {
|
||||||
|
await request("/service/info/check", "POST");
|
||||||
|
const info = await request("/service/info", "GET");
|
||||||
|
if (mounted && info) setServiceInfo(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
runServiceCheck().catch(() => {});
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
runServiceCheck().catch(() => {});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
const setUiMode = (nextMode) => {
|
||||||
|
const mode = nextMode === "easy" ? "easy" : "dba";
|
||||||
|
setUiModeState(mode);
|
||||||
|
localStorage.setItem(UI_MODE_KEY, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
tokens,
|
||||||
|
me,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
uiMode,
|
||||||
|
setUiMode,
|
||||||
|
alertStatus,
|
||||||
|
alertToasts,
|
||||||
|
dismissAlertToast,
|
||||||
|
serviceInfo,
|
||||||
|
serviceUpdateAvailable: !!serviceInfo?.update_available,
|
||||||
|
}),
|
||||||
|
[tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
|
||||||
|
);
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
|
# DEV default only. Use strong unique credentials in production.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Never hardcode in source. Rotate regularly.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Never hardcode in source. Rotate with re-encryption plan.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 8080.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
VITE_API_URL=http://localhost:8000/api/v1
|
# Base API URL used at frontend build time.
|
||||||
|
# For reverse proxy + SSL, keep this relative to avoid mixed-content issues.
|
||||||
|
# Example direct mode: VITE_API_URL=http://localhost:8000/api/v1
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
48
ops/profiles/prod/.env.production.example
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# NexaPG production profile (reverse proxy + HTTPS)
|
||||||
|
# Copy to .env and adjust values for your environment.
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
APP_NAME=NexaPG Monitor
|
||||||
|
ENVIRONMENT=prod
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Core Database
|
||||||
|
# ------------------------------
|
||||||
|
DB_NAME=nexapg
|
||||||
|
DB_USER=nexapg
|
||||||
|
DB_PASSWORD=change_me
|
||||||
|
DB_PORT=5433
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Backend
|
||||||
|
# ------------------------------
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
JWT_SECRET_KEY=replace_with_long_random_secret
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
|
|
||||||
|
# Production CORS:
|
||||||
|
# - no wildcard
|
||||||
|
# - set exact public UI origin(s)
|
||||||
|
CORS_ORIGINS=https://monitor.example.com
|
||||||
|
|
||||||
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5
|
||||||
|
ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10
|
||||||
|
|
||||||
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Keep frontend API base relative to avoid HTTPS mixed-content.
|
||||||
|
FRONTEND_PORT=5173
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
49
ops/profiles/prod/nginx/nexapg.conf
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# NGINX reverse proxy profile for NexaPG (HTTPS).
|
||||||
|
# Replace monitor.example.com and certificate paths.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name monitor.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name monitor.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# Baseline security headers
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Frontend app
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
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;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API forwarding to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
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;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ops/scripts/bootstrap-compose.sh
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# bash bootstrap-compose.sh
|
||||||
|
# BASE_URL="https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main" bash bootstrap-compose.sh
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main}"
|
||||||
|
|
||||||
|
echo "[bootstrap] Using base URL: ${BASE_URL}"
|
||||||
|
|
||||||
|
fetch_file() {
|
||||||
|
local path="$1"
|
||||||
|
local out="$2"
|
||||||
|
|
||||||
|
if command -v wget >/dev/null 2>&1; then
|
||||||
|
wget -q -O "${out}" "${BASE_URL}/${path}"
|
||||||
|
elif command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -fsSL "${BASE_URL}/${path}" -o "${out}"
|
||||||
|
else
|
||||||
|
echo "[bootstrap] ERROR: wget or curl is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_file "docker-compose.yml" "docker-compose.yml"
|
||||||
|
fetch_file ".env.example" ".env.example"
|
||||||
|
fetch_file "Makefile" "Makefile"
|
||||||
|
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "[bootstrap] Created .env from .env.example"
|
||||||
|
else
|
||||||
|
echo "[bootstrap] .env already exists, keeping it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[bootstrap] Next steps:"
|
||||||
|
echo " 1) Edit .env (set JWT_SECRET_KEY and ENCRYPTION_KEY at minimum)"
|
||||||
|
echo " 2) Run: make up"
|
||||||
|
echo
|
||||||
38
ops/scripts/validate_proxy_profile.sh
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "[proxy-profile] validating reverse-proxy and mixed-content guardrails"
|
||||||
|
|
||||||
|
require_pattern() {
|
||||||
|
local file="$1"
|
||||||
|
local pattern="$2"
|
||||||
|
local message="$3"
|
||||||
|
if ! grep -Eq "$pattern" "$file"; then
|
||||||
|
echo "[proxy-profile] FAIL: $message ($file)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend should default to relative API base in container builds.
|
||||||
|
require_pattern "frontend/Dockerfile" "ARG VITE_API_URL=/api/v1" \
|
||||||
|
"VITE_API_URL default must be relative (/api/v1)"
|
||||||
|
|
||||||
|
# Frontend runtime proxy should forward /api with forward headers.
|
||||||
|
require_pattern "frontend/nginx.conf" "location /api/" \
|
||||||
|
"frontend nginx must proxy /api/"
|
||||||
|
require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-Proto" \
|
||||||
|
"frontend nginx must set X-Forwarded-Proto"
|
||||||
|
require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-For" \
|
||||||
|
"frontend nginx must set X-Forwarded-For"
|
||||||
|
require_pattern "frontend/nginx.conf" "proxy_set_header Host" \
|
||||||
|
"frontend nginx must forward Host"
|
||||||
|
|
||||||
|
# Mixed-content guard in frontend API client.
|
||||||
|
require_pattern "frontend/src/api.js" "window\\.location\\.protocol === \"https:\".*parsed\\.protocol === \"http:\"" \
|
||||||
|
"frontend api client must contain HTTPS mixed-content protection"
|
||||||
|
|
||||||
|
# Production profile must not use wildcard CORS.
|
||||||
|
require_pattern "ops/profiles/prod/.env.production.example" "^CORS_ORIGINS=https://[^*]+$" \
|
||||||
|
"production profile must use explicit HTTPS CORS origins"
|
||||||
|
|
||||||
|
echo "[proxy-profile] PASS"
|
||||||
3
ops/security/pip-audit-allowlist.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"entries": []
|
||||||
|
}
|
||||||