Compare commits
116 Commits
f12dd46c21
...
0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 8c94a30a81 | |||
| 5ab7ae1064 | |||
| adf1a4f6fd | |||
| 2cea3ef1c2 | |||
| c4f4340642 | |||
| d29473d3b1 | |||
| 672473603e | |||
| 7b011326a6 | |||
| 3d8bbbb2d6 | |||
| 7997773129 |
44
.env.example
@@ -1,28 +1,60 @@
|
||||
# App
|
||||
# ------------------------------
|
||||
# Application
|
||||
# ------------------------------
|
||||
# Display name used in API docs/UI.
|
||||
APP_NAME=NexaPG Monitor
|
||||
# Runtime environment: dev | staging | prod | test
|
||||
ENVIRONMENT=dev
|
||||
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Core DB
|
||||
# ------------------------------
|
||||
# Core Database (internal metadata DB)
|
||||
# ------------------------------
|
||||
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||
DB_NAME=nexapg
|
||||
DB_USER=nexapg
|
||||
DB_PASSWORD=nexapg
|
||||
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||
DB_PORT=5433
|
||||
|
||||
# Backend
|
||||
# ------------------------------
|
||||
# Backend API
|
||||
# ------------------------------
|
||||
# Host port mapped to backend container port 8000.
|
||||
BACKEND_PORT=8000
|
||||
# JWT signing secret. Change this in every non-local environment.
|
||||
JWT_SECRET_KEY=change_this_super_secret
|
||||
JWT_ALGORITHM=HS256
|
||||
# Access token lifetime in minutes.
|
||||
JWT_ACCESS_TOKEN_MINUTES=15
|
||||
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||
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.
|
||||
# Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
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
|
||||
# Target polling interval in seconds.
|
||||
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_PASSWORD=ChangeMe123!
|
||||
|
||||
# ------------------------------
|
||||
# Frontend
|
||||
# ------------------------------
|
||||
# Host port mapped to frontend container port 80.
|
||||
FRONTEND_PORT=5173
|
||||
VITE_API_URL=http://localhost:8000/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
|
||||
107
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
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: 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
|
||||
if [ -z "$NS" ]; then
|
||||
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
3
Makefile
@@ -1,7 +1,8 @@
|
||||
.PHONY: up down logs migrate
|
||||
|
||||
up:
|
||||
docker compose up -d --build
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
456
README.md
@@ -1,113 +1,423 @@
|
||||
# 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)
|
||||
- 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`
|
||||
## Table of Contents
|
||||
|
||||
## 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)
|
||||
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Security Notes](#security-notes)
|
||||
|
||||
- `backend/` FastAPI App
|
||||
- `frontend/` React (Vite) App
|
||||
- `ops/` Scripts
|
||||
- `docker-compose.yml` Stack
|
||||
- `.env.example` Konfigurationsvorlage
|
||||
## Highlights
|
||||
|
||||
## 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
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
make up
|
||||
```
|
||||
### Application
|
||||
|
||||
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`
|
||||
- Backend API: `http://localhost:8000/api/v1`
|
||||
- OpenAPI: `http://localhost:8000/docs`
|
||||
### Core Database
|
||||
|
||||
Default Admin (aus `.env`):
|
||||
- Email: `admin@example.com`
|
||||
- Passwort: `ChangeMe123!`
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `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
|
||||
make up
|
||||
make down
|
||||
make logs
|
||||
make migrate
|
||||
```
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `BACKEND_PORT` | Host port mapped to backend container port `8000` |
|
||||
| `JWT_SECRET_KEY` | JWT signing secret |
|
||||
| `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 `80` |
|
||||
|
||||
## 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/refresh`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `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}/activity`
|
||||
- `GET /api/v1/targets/{id}/top-queries`
|
||||
- Admin-only CRUD users:
|
||||
- `GET /api/v1/admin/users`
|
||||
- `POST /api/v1/admin/users`
|
||||
- `PUT /api/v1/admin/users/{user_id}`
|
||||
- `DELETE /api/v1/admin/users/{user_id}`
|
||||
- `GET /api/v1/targets/{id}/overview`
|
||||
|
||||
## Security Notes
|
||||
### Alerts
|
||||
|
||||
- Keine Secrets hardcoded
|
||||
- Passwoerter als Argon2 Hash
|
||||
- Target-Credentials verschluesselt (Fernet)
|
||||
- CORS via Env steuerbar
|
||||
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
|
||||
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
|
||||
- `GET /api/v1/alerts/status`
|
||||
- `GET /api/v1/alerts/definitions`
|
||||
- `POST /api/v1/alerts/definitions`
|
||||
- `PUT /api/v1/alerts/definitions/{id}`
|
||||
- `DELETE /api/v1/alerts/definitions/{id}`
|
||||
- `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.
|
||||
Beispiel:
|
||||
- `GET /api/v1/admin/users`
|
||||
- `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
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 PYTHONUNBUFFERED=1
|
||||
@@ -6,7 +7,17 @@ ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
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
|
||||
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 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.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(me.router, tags=["auth"])
|
||||
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_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 app.core.db import get_db
|
||||
from app.core.deps import require_roles
|
||||
from app.core.errors import api_error
|
||||
from app.core.security import hash_password
|
||||
from app.models.models import User
|
||||
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:
|
||||
exists = await db.scalar(select(User).where(User.email == payload.email))
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role)
|
||||
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
|
||||
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)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
@@ -40,10 +47,17 @@ async def update_user(
|
||||
) -> UserOut:
|
||||
user = await db.scalar(select(User).where(User.id == user_id))
|
||||
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)
|
||||
if "password" in update_data and update_data["password"]:
|
||||
user.password_hash = hash_password(update_data.pop("password"))
|
||||
next_email = update_data.get("email")
|
||||
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():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
@@ -55,10 +69,10 @@ async def update_user(
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
||||
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))
|
||||
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.commit()
|
||||
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 jose import JWTError, jwt
|
||||
import jwt
|
||||
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.core.errors import api_error
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.models.models import User
|
||||
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:
|
||||
user = await db.scalar(select(User).where(User.email == payload.email))
|
||||
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})
|
||||
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:
|
||||
try:
|
||||
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
||||
except jwt.InvalidTokenError as 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":
|
||||
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 = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||
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={})
|
||||
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.errors import api_error
|
||||
from app.core.security import hash_password, verify_password
|
||||
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()
|
||||
|
||||
@@ -9,3 +14,27 @@ router = APIRouter()
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||
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 uuid import uuid4
|
||||
|
||||
import asyncpg
|
||||
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 app.core.db import get_db
|
||||
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.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.collector import build_target_dsn
|
||||
from app.services.crypto import encrypt_secret
|
||||
@@ -17,10 +28,120 @@ from app.services.overview_service import get_target_overview
|
||||
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])
|
||||
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()
|
||||
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)
|
||||
@@ -29,29 +150,122 @@ async def create_target(
|
||||
user: User = Depends(require_roles("admin", "operator")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> 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(
|
||||
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,
|
||||
port=payload.port,
|
||||
dbname=payload.dbname,
|
||||
username=payload.username,
|
||||
encrypted_password=encrypt_secret(payload.password),
|
||||
encrypted_password=encrypted_password,
|
||||
sslmode=payload.sslmode,
|
||||
use_pg_stat_statements=payload.use_pg_stat_statements,
|
||||
tags=payload.tags,
|
||||
)
|
||||
db.add(target)
|
||||
await db.commit()
|
||||
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})
|
||||
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)
|
||||
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))
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target not found")
|
||||
return TargetOut.model_validate(target)
|
||||
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
|
||||
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)
|
||||
@@ -63,17 +277,82 @@ async def update_target(
|
||||
) -> TargetOut:
|
||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||
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)
|
||||
owner_user_ids = updates.pop("owner_user_ids", None)
|
||||
if "password" in updates:
|
||||
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
||||
for key, value in updates.items():
|
||||
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.refresh(target)
|
||||
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}")
|
||||
@@ -84,7 +363,7 @@ async def delete_target(
|
||||
) -> dict:
|
||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||
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.commit()
|
||||
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:
|
||||
try:
|
||||
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")
|
||||
@@ -120,7 +414,7 @@ async def get_locks(target_id: int, user: User = Depends(get_current_user), db:
|
||||
_ = user
|
||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||
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)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
@@ -141,7 +435,7 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
|
||||
_ = user
|
||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||
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)
|
||||
try:
|
||||
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])
|
||||
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
|
||||
_ = 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 = (
|
||||
await db.scalars(
|
||||
select(QueryStat)
|
||||
@@ -188,5 +487,20 @@ async def get_overview(target_id: int, user: User = Depends(get_current_user), d
|
||||
_ = user
|
||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||
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)
|
||||
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_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
NEXAPG_VERSION = "0.2.2"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
@@ -25,9 +27,17 @@ class Settings(BaseSettings):
|
||||
encryption_key: str
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
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_password: str = "ChangeMe123!"
|
||||
|
||||
@property
|
||||
def app_version(self) -> str:
|
||||
return NEXAPG_VERSION
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
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.errors import api_error
|
||||
from app.models.models import User
|
||||
|
||||
settings = get_settings()
|
||||
@@ -16,27 +17,42 @@ async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
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
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=api_error("invalid_token", "Invalid token"),
|
||||
) from exc
|
||||
|
||||
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 = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||
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
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
||||
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 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 jose import jwt
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
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.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from sqlalchemy import select
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
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.security import hash_password
|
||||
from app.models.models import User
|
||||
@@ -57,4 +62,67 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
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)
|
||||
|
||||
@@ -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 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 app.core.db import Base
|
||||
|
||||
@@ -9,11 +9,18 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
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)
|
||||
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)
|
||||
|
||||
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):
|
||||
@@ -27,11 +34,28 @@ class Target(Base):
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
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)
|
||||
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")
|
||||
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):
|
||||
@@ -73,3 +97,84 @@ class AuditLog(Base):
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
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
|
||||
username: str
|
||||
sslmode: str = "prefer"
|
||||
use_pg_stat_statements: bool = True
|
||||
owner_user_ids: list[int] = Field(default_factory=list)
|
||||
tags: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TargetCreate(TargetBase):
|
||||
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):
|
||||
@@ -24,6 +36,8 @@ class TargetUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
password: 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
|
||||
|
||||
|
||||
@@ -32,3 +46,13 @@ class TargetOut(TargetBase):
|
||||
created_at: datetime
|
||||
|
||||
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 pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
@@ -13,11 +15,27 @@ class UserOut(BaseModel):
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
password: str
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
password: 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 logging
|
||||
from datetime import datetime, timezone
|
||||
from random import uniform
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -13,6 +14,11 @@ import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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:
|
||||
@@ -41,7 +47,19 @@ async def collect_target(target: Target) -> None:
|
||||
try:
|
||||
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
|
||||
WHERE datname = current_database()
|
||||
"""
|
||||
@@ -55,18 +73,44 @@ async def collect_target(target: Target) -> None:
|
||||
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(
|
||||
"""
|
||||
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
||||
FROM pg_stat_bgwriter
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
bgwriter = None
|
||||
|
||||
if stat_db is None:
|
||||
stat_db = {
|
||||
"numbackends": 0,
|
||||
"xact_commit": 0,
|
||||
"xact_rollback": 0,
|
||||
"deadlocks": 0,
|
||||
"temp_files": 0,
|
||||
"temp_bytes": 0,
|
||||
"blk_read_time": 0,
|
||||
"blk_write_time": 0,
|
||||
"blks_hit": 0,
|
||||
"blks_read": 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"])
|
||||
|
||||
query_rows = []
|
||||
if target.use_pg_stat_statements:
|
||||
try:
|
||||
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_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, "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, "locks_total", lock_count, {})
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
while not stop_event.is_set():
|
||||
cycle_started = asyncio.get_running_loop().time()
|
||||
await collect_once()
|
||||
elapsed = asyncio.get_running_loop().time() - cycle_started
|
||||
sleep_for = max(0.0, settings.poll_interval_seconds - elapsed)
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -17,10 +17,10 @@ class DiskSpaceProvider:
|
||||
class NullDiskSpaceProvider(DiskSpaceProvider):
|
||||
async def get_free_bytes(self, target_host: str) -> DiskSpaceProbeResult:
|
||||
return DiskSpaceProbeResult(
|
||||
source="none",
|
||||
source="agentless",
|
||||
status="unavailable",
|
||||
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,
|
||||
"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(
|
||||
conn,
|
||||
"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
|
||||
gunicorn==23.0.0
|
||||
sqlalchemy[asyncio]==2.0.44
|
||||
@@ -7,7 +8,7 @@ alembic==1.16.5
|
||||
pydantic==2.11.7
|
||||
pydantic-settings==2.11.0
|
||||
email-validator==2.2.0
|
||||
python-jose[cryptography]==3.5.0
|
||||
PyJWT==2.11.0
|
||||
passlib[argon2]==1.7.4
|
||||
cryptography==45.0.7
|
||||
python-multipart==0.0.20
|
||||
cryptography==46.0.5
|
||||
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
|
||||
@@ -18,8 +18,8 @@ services:
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
image: nesterovicit/nexapg-backend:latest
|
||||
pull_policy: always
|
||||
container_name: nexapg-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -47,16 +47,14 @@ services:
|
||||
- "${BACKEND_PORT}:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL}
|
||||
image: nesterovicit/nexapg-frontend:latest
|
||||
pull_policy: always
|
||||
container_name: nexapg-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
- "${FRONTEND_PORT}:8080"
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
|
||||
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 |
@@ -7,8 +7,12 @@ ARG VITE_API_URL=/api/v1
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
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
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1
|
||||
USER 101
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD nginx -t || exit 1
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend: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;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.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 { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "./state";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { TargetsPage } from "./pages/TargetsPage";
|
||||
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
||||
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
||||
import { AlertsPage } from "./pages/AlertsPage";
|
||||
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
|
||||
import { UserSettingsPage } from "./pages/UserSettingsPage";
|
||||
|
||||
function Protected({ children }) {
|
||||
const { tokens } = useAuth();
|
||||
@@ -16,24 +19,136 @@ function Protected({ 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 fullName = [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<img src="/nexapg-logo.svg" alt="NexaPG" className="brand-logo" />
|
||||
<h1>NexaPG</h1>
|
||||
<nav>
|
||||
<Link to="/">Dashboard</Link>
|
||||
<Link to="/targets">Targets</Link>
|
||||
<Link to="/query-insights">Query Insights</Link>
|
||||
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>}
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
<NavLink to="/" end className={navClass}>
|
||||
<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>
|
||||
</NavLink>
|
||||
<NavLink to="/targets" className={navClass}>
|
||||
<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>
|
||||
</NavLink>
|
||||
<NavLink to="/query-insights" className={navClass}>
|
||||
<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>
|
||||
</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" && (
|
||||
<>
|
||||
<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>
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="profile">
|
||||
<div>{me?.email}</div>
|
||||
<div className="role">{me?.role}</div>
|
||||
<button onClick={logout}>Logout</button>
|
||||
<div className="mode-switch-block">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +167,9 @@ export function App() {
|
||||
<Route path="/targets" element={<TargetsPage />} />
|
||||
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
|
||||
function resolveApiUrl() {
|
||||
const raw = (import.meta.env.VITE_API_URL || "").trim();
|
||||
const fallback = "/api/v1";
|
||||
if (!raw) return fallback;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw, window.location.origin);
|
||||
if (window.location.protocol === "https:" && parsed.protocol === "http:") {
|
||||
// Avoid mixed-content when UI is served over HTTPS.
|
||||
parsed.protocol = "https:";
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL = resolveApiUrl();
|
||||
|
||||
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
||||
const headers = {
|
||||
@@ -18,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || `HTTP ${res.status}`);
|
||||
const raw = await res.text();
|
||||
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;
|
||||
return res.json();
|
||||
|
||||
@@ -2,27 +2,87 @@ import React, { useEffect, useState } from "react";
|
||||
import { apiFetch } from "../api";
|
||||
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() {
|
||||
const { tokens, refresh, me } = useAuth();
|
||||
const emptyCreateForm = { email: "", first_name: "", last_name: "", password: "", role: "viewer" };
|
||||
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 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(() => {
|
||||
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
|
||||
setForm({ email: "", password: "", role: "viewer" });
|
||||
setForm(emptyCreateForm);
|
||||
await load();
|
||||
} catch (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 (
|
||||
<div>
|
||||
<h2>Admin Users</h2>
|
||||
<div className="admin-settings-page">
|
||||
<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>}
|
||||
<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
|
||||
type="password"
|
||||
value={form.password}
|
||||
placeholder="passwort"
|
||||
placeholder="Set initial password"
|
||||
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 })}>
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="operator">operator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button>User anlegen</button>
|
||||
</div>
|
||||
<div className="form-actions field-full">
|
||||
<button className="primary-btn" type="submit">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="card">
|
||||
</div>
|
||||
|
||||
<div className="card admin-users-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Action</th>
|
||||
@@ -69,16 +257,273 @@ export function AdminUsersPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
|
||||
<tr key={u.id} className="admin-user-row">
|
||||
<td className="user-col-id">{u.id}</td>
|
||||
<td className="user-col-name">
|
||||
{editingUserId === u.id ? (
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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() {
|
||||
const { tokens, refresh } = useAuth();
|
||||
const { tokens, refresh, alertStatus } = useAuth();
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [openGroups, setOpenGroups] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -13,8 +24,10 @@ export function DashboardPage() {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await apiFetch("/targets", {}, tokens, refresh);
|
||||
if (active) setTargets(data);
|
||||
const targetRows = await apiFetch("/targets", {}, tokens, refresh);
|
||||
if (active) {
|
||||
setTargets(targetRows);
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError(String(e.message || e));
|
||||
} finally {
|
||||
@@ -26,48 +39,172 @@ export function DashboardPage() {
|
||||
};
|
||||
}, [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>;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<div className="grid three">
|
||||
<div className="card stat">
|
||||
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
|
||||
<div className="dashboard-kpis-grid">
|
||||
<div className="card stat kpi-card">
|
||||
<div className="kpi-orb blue" />
|
||||
<strong>{targets.length}</strong>
|
||||
<span>Targets</span>
|
||||
<span className="kpi-label">Total Targets</span>
|
||||
</div>
|
||||
<div className="card stat">
|
||||
<strong>{targets.length}</strong>
|
||||
<span>Status OK (placeholder)</span>
|
||||
<div className="card stat kpi-card ok">
|
||||
<div className="kpi-orb green" />
|
||||
<strong>{okCount}</strong>
|
||||
<span className="kpi-label">Status OK</span>
|
||||
</div>
|
||||
<div className="card stat">
|
||||
<strong>0</strong>
|
||||
<span>Alerts (placeholder)</span>
|
||||
<div className="card stat kpi-card warning">
|
||||
<div className="kpi-orb amber" />
|
||||
<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 className="card">
|
||||
|
||||
<div className="card dashboard-targets-card">
|
||||
<div className="dashboard-targets-head">
|
||||
<div>
|
||||
<h3>Targets</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>DB</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.name}</td>
|
||||
<td>{t.host}:{t.port}</td>
|
||||
<td>{t.dbname}</td>
|
||||
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<span>{filteredTargets.length} shown of {targets.length} registered</span>
|
||||
</div>
|
||||
<div className="dashboard-target-search">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, host, or database..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-target-list">
|
||||
{groupedRows.map((row) => {
|
||||
if (row.type === "single") {
|
||||
const t = row.target;
|
||||
const severity = targetSeverities.get(t.id) || "ok";
|
||||
return (
|
||||
<article className="dashboard-target-card" key={`single-${t.id}`}>
|
||||
<div className="target-main">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useAuth } from "../state";
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("admin@example.com");
|
||||
const [password, setPassword] = useState("ChangeMe123!");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function LoginPage() {
|
||||
await login(email, password);
|
||||
navigate("/");
|
||||
} catch {
|
||||
setError("Login fehlgeschlagen");
|
||||
setError("Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -27,13 +27,32 @@ export function LoginPage() {
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<h2>Login</h2>
|
||||
<label>Email</label>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<label>Passwort</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<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>
|
||||
<h2>Welcome back</h2>
|
||||
<p className="login-subtitle">Sign in to access monitoring and query insights.</p>
|
||||
<div className="input-shell">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-shell">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
||||
<button className="login-cta" disabled={loading}>{loading ? "Please wait..." : "Sign in"}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,46 +1,165 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { apiFetch } from "../api";
|
||||
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() {
|
||||
const { tokens, refresh } = useAuth();
|
||||
const refreshRef = useRef(refresh);
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [rows, setRows] = useState([]);
|
||||
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const t = await apiFetch("/targets", {}, tokens, refresh);
|
||||
setTargets(t);
|
||||
if (t.length > 0) setTargetId(String(t[0].id));
|
||||
const supported = t.filter((item) => item.use_pg_stat_statements !== false);
|
||||
setTargets(supported);
|
||||
if (supported.length > 0) setTargetId(String(supported[0].id));
|
||||
else setTargetId("");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetId) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
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);
|
||||
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) {
|
||||
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 (
|
||||
<div>
|
||||
<div className="query-insights-page">
|
||||
<h2>Query Insights</h2>
|
||||
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
|
||||
{error && <div className="card error">{error}</div>}
|
||||
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
|
||||
{targets.length === 0 && !loading && (
|
||||
<div className="card">
|
||||
<label>Target </label>
|
||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
No targets with enabled <code>pg_stat_statements</code> are available.
|
||||
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) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
@@ -48,32 +167,141 @@ export function QueryInsightsPage() {
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Priority</th>
|
||||
<th>Calls</th>
|
||||
<th>Total ms</th>
|
||||
<th>Mean ms</th>
|
||||
<th>Rows</th>
|
||||
<th>Query</th>
|
||||
<th>Query Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{new Date(r.ts).toLocaleString()}</td>
|
||||
{paged.map((r, i) => {
|
||||
const state = classifyQuery(r);
|
||||
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.total_time.toFixed(2)}</td>
|
||||
<td>{r.mean_time.toFixed(2)}</td>
|
||||
<td>{Number(r.total_time || 0).toFixed(2)}</td>
|
||||
<td>{Number(r.mean_time || 0).toFixed(2)}</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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</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 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 { useParams } from "react-router-dom";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { apiFetch } from "../api";
|
||||
import { useAuth } from "../state";
|
||||
@@ -36,6 +36,50 @@ function formatSeconds(value) {
|
||||
return `${(value / 3600).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 2) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) return "-";
|
||||
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 }) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const row = payload[0]?.payload || {};
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<div className="chart-tooltip-time">{label}</div>
|
||||
<div className="chart-tooltip-item c1">connections: {formatNumber(row.connections, 0)}</div>
|
||||
<div className="chart-tooltip-item c2">tps: {formatNumber(row.tps, 2)}</div>
|
||||
<div className="chart-tooltip-item c3">cache: {formatNumber(row.cache, 2)}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const { from, to } = toQueryRange(range);
|
||||
return apiFetch(
|
||||
@@ -48,57 +92,186 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
|
||||
|
||||
export function TargetDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { tokens, refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { tokens, refresh, uiMode } = useAuth();
|
||||
const [range, setRange] = useState("1h");
|
||||
const [liveMode, setLiveMode] = useState(false);
|
||||
const [series, setSeries] = useState({});
|
||||
const [locks, setLocks] = useState([]);
|
||||
const [activity, setActivity] = useState([]);
|
||||
const [overview, setOverview] = 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 [loading, setLoading] = useState(true);
|
||||
const refreshRef = useRef(refresh);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
const loadAll = async () => {
|
||||
if (!series.connections?.length) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const [connections, xacts, cache, locksTable, activityTable, overviewData] = await Promise.all([
|
||||
loadMetric(id, "connections_total", range, tokens, refresh),
|
||||
loadMetric(id, "xacts_total", range, tokens, refresh),
|
||||
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
|
||||
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
|
||||
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
|
||||
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
|
||||
const [connections, xacts, cache, targetInfo, ownerRows, allTargets] = await Promise.all([
|
||||
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
|
||||
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
|
||||
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
|
||||
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
|
||||
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
|
||||
apiFetch("/targets", {}, tokens, refreshRef.current),
|
||||
]);
|
||||
if (!active) return;
|
||||
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);
|
||||
setActivity(activityTable);
|
||||
setOverview(overviewData);
|
||||
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("");
|
||||
} catch (e) {
|
||||
if (active) setError(String(e.message || e));
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
loadAll();
|
||||
return () => {
|
||||
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(
|
||||
() =>
|
||||
(series.connections || []).map((point, idx) => ({
|
||||
() => {
|
||||
const con = series.connections || [];
|
||||
const xacts = series.xacts || [];
|
||||
const cache = series.cache || [];
|
||||
return con.map((point, idx) => {
|
||||
const prev = xacts[idx - 1];
|
||||
const curr = xacts[idx];
|
||||
let tps = 0;
|
||||
if (prev && curr) {
|
||||
const dt = (new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
|
||||
const dx = (curr.value || 0) - (prev.value || 0);
|
||||
if (dt > 0 && dx >= 0) {
|
||||
tps = dx / dt;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ts: new Date(point.ts).toLocaleTimeString(),
|
||||
connections: point.value,
|
||||
xacts: series.xacts?.[idx]?.value || 0,
|
||||
cache: series.cache?.[idx]?.value || 0,
|
||||
})),
|
||||
tps,
|
||||
cache: (cache[idx]?.value || 0) * 100,
|
||||
};
|
||||
});
|
||||
},
|
||||
[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>;
|
||||
|
||||
const role = overview?.instance?.role || "-";
|
||||
@@ -107,31 +280,150 @@ export function TargetDetailPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Target Detail #{id}</h2>
|
||||
{overview && (
|
||||
<h2>
|
||||
Target Detail {targetMeta?.name || `#${id}`}
|
||||
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||
</h2>
|
||||
{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">
|
||||
<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><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
|
||||
<div>
|
||||
<span>Role</span>
|
||||
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
||||
</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>
|
||||
</div>
|
||||
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
||||
<div><span>Port</span><strong>{overview.instance.port ?? "-"}</strong></div>
|
||||
<div title="Groesse der aktuell verbundenen Datenbank">
|
||||
<div>
|
||||
<span>Target Port</span>
|
||||
<strong>{targetMeta?.port ?? "-"}</strong>
|
||||
</div>
|
||||
<div title="Current database total size">
|
||||
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
||||
</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>
|
||||
</div>
|
||||
<div title="Optional ueber Agent/SSH ermittelbar">
|
||||
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
||||
<div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
|
||||
<span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
|
||||
</div>
|
||||
<div title="Zeitliche Replikationsverzoegerung auf Standby">
|
||||
<div title="Replication replay delay on standby">
|
||||
<span>Replay Lag</span>
|
||||
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
||||
{formatSeconds(overview.replication.replay_lag_seconds)}
|
||||
@@ -140,6 +432,12 @@ export function TargetDetailPage() {
|
||||
<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>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 className="grid two">
|
||||
@@ -212,22 +510,43 @@ export function TargetDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
<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) => (
|
||||
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => {
|
||||
setLiveMode(false);
|
||||
setRange(r);
|
||||
}}
|
||||
className={r === range ? "active" : ""}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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%">
|
||||
<LineChart data={chartData}>
|
||||
<XAxis dataKey="ts" hide />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} />
|
||||
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} />
|
||||
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
||||
<Tooltip content={<MetricsTooltip />} />
|
||||
<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} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</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 { apiFetch } from "../api";
|
||||
import { useAuth } from "../state";
|
||||
|
||||
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: "",
|
||||
host: "",
|
||||
port: 5432,
|
||||
@@ -11,22 +26,120 @@ const emptyForm = {
|
||||
username: "",
|
||||
password: "",
|
||||
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() {
|
||||
const { tokens, refresh, me } = useAuth();
|
||||
const [targets, setTargets] = useState([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
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 load = async () => {
|
||||
setLoading(true);
|
||||
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));
|
||||
}
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
@@ -37,7 +150,7 @@ export function TargetsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
}, [canManage]);
|
||||
|
||||
const createTarget = async (e) => {
|
||||
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) => {
|
||||
if (!confirm("Target löschen?")) return;
|
||||
if (!confirm("Delete target?")) return;
|
||||
try {
|
||||
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
||||
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 (
|
||||
<div>
|
||||
<div className="targets-page">
|
||||
<h2>Targets Management</h2>
|
||||
{error && <div className="card error">{error}</div>}
|
||||
|
||||
{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">
|
||||
<label>Name</label>
|
||||
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
<small>Eindeutiger Anzeigename im Dashboard.</small>
|
||||
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<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 />
|
||||
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
|
||||
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Port</label>
|
||||
<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 className="field">
|
||||
<label>DB Name</label>
|
||||
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
||||
<small>Name der Datenbank, die überwacht werden soll.</small>
|
||||
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
|
||||
<input
|
||||
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 className="field">
|
||||
<label>Username</label>
|
||||
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||
<small>DB User mit Leserechten auf Stats-Views.</small>
|
||||
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Password</label>
|
||||
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||
<small>Wird verschlüsselt in der Core-DB gespeichert.</small>
|
||||
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>SSL Mode</label>
|
||||
@@ -104,32 +306,162 @@ export function TargetsPage() {
|
||||
<option value="require">require</option>
|
||||
</select>
|
||||
<small>
|
||||
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
|
||||
If you see "rejected SSL upgrade", switch to <code>disable</code>.
|
||||
</small>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label> </label>
|
||||
<button>Target anlegen</button>
|
||||
<div className="field toggle-field">
|
||||
<label>Query Insights Source</label>
|
||||
<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>
|
||||
</form>
|
||||
{testState.message && (
|
||||
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
|
||||
)}
|
||||
{canManage && (
|
||||
<div className="card tips">
|
||||
<strong>Troubleshooting</strong>
|
||||
<p>
|
||||
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
|
||||
</p>
|
||||
<p>
|
||||
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
|
||||
</p>
|
||||
<p>
|
||||
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{canManage && editing && (
|
||||
<section className="card">
|
||||
<h3>Edit Target</h3>
|
||||
<form className="target-form grid two" onSubmit={saveEdit}>
|
||||
<div className="field">
|
||||
<label>Name</label>
|
||||
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
|
||||
</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 ? (
|
||||
<p>Lade Targets...</p>
|
||||
<p>Loading targets...</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
@@ -137,7 +469,9 @@ export function TargetsPage() {
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>DB</th>
|
||||
<th>Aktionen</th>
|
||||
<th>Owners</th>
|
||||
<th>Query Insights</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -146,9 +480,43 @@ export function TargetsPage() {
|
||||
<td>{t.name}</td>
|
||||
<td>{t.host}:{t.port}</td>
|
||||
<td>{t.dbname}</td>
|
||||
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
|
||||
<td>
|
||||
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
|
||||
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
|
||||
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
|
||||
{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>
|
||||
</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";
|
||||
|
||||
const AuthCtx = createContext(null);
|
||||
const UI_MODE_KEY = "nexapg_ui_mode";
|
||||
|
||||
function loadStorage() {
|
||||
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 }) {
|
||||
const initial = loadStorage();
|
||||
const [tokens, setTokens] = useState(initial?.tokens || 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) => {
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
# App
|
||||
# ------------------------------
|
||||
# Application
|
||||
# ------------------------------
|
||||
# Display name used in API docs/UI.
|
||||
APP_NAME=NexaPG Monitor
|
||||
# Runtime environment: dev | staging | prod | test
|
||||
ENVIRONMENT=dev
|
||||
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Core DB
|
||||
# ------------------------------
|
||||
# Core Database (internal metadata DB)
|
||||
# ------------------------------
|
||||
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||
DB_NAME=nexapg
|
||||
DB_USER=nexapg
|
||||
DB_PASSWORD=nexapg
|
||||
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||
DB_PORT=5433
|
||||
|
||||
# Backend
|
||||
# ------------------------------
|
||||
# Backend API
|
||||
# ------------------------------
|
||||
# Host port mapped to backend container port 8000.
|
||||
BACKEND_PORT=8000
|
||||
# JWT signing secret. Change this in every non-local environment.
|
||||
JWT_SECRET_KEY=change_this_super_secret
|
||||
JWT_ALGORITHM=HS256
|
||||
# Access token lifetime in minutes.
|
||||
JWT_ACCESS_TOKEN_MINUTES=15
|
||||
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||
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.
|
||||
# Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
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
|
||||
# Target polling interval in seconds.
|
||||
POLL_INTERVAL_SECONDS=30
|
||||
# Initial admin bootstrap user (created on first startup if not present).
|
||||
INIT_ADMIN_EMAIL=admin@example.com
|
||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||
|
||||
# ------------------------------
|
||||
# Frontend
|
||||
# ------------------------------
|
||||
# Host port mapped to frontend container port 80.
|
||||
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
|
||||
|
||||
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
|
||||