Compare commits
60 Commits
8c94a30a81
...
0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a7236282 | |||
| bd53bce231 | |||
| 18d6289807 | |||
| e24681332d | |||
| 0445a72764 | |||
| fd24a3a548 | |||
| 7619757ed5 | |||
| 45d2173d1e | |||
| 08ee35e25f | |||
| 91642e745f | |||
| fa8958934f | |||
| 1b12c01366 | |||
| 4bc178b720 | |||
| 8e5a549c2c | |||
| c437e72c2b | |||
| e5a9acfa91 | |||
| 1bab5cd16d | |||
| 6f36f73f8e | |||
| 7599b3742d | |||
| ec05163a04 | |||
| 918bb132ef | |||
| 505b93be4f | |||
| 648ff07651 | |||
| ea26ef4d33 | |||
| 7acfb498b4 | |||
| 51eece14c2 | |||
| 882ad2dca8 | |||
| 35a76aaca6 | |||
| ff6d7998c3 | |||
| a0ba4e1314 | |||
| 9eb94545a1 | |||
| 528a720329 | |||
| 55f5652572 | |||
| d4f176c731 | |||
| 7957052172 | |||
| 5674f2ea45 | |||
| a8b7d9f54a | |||
| 2747e62ff8 | |||
| 712bec3fea | |||
| 839943d9fd | |||
| 606d113f34 | |||
| 2c727c361e | |||
| c74461ddfb | |||
| d0e8154c21 | |||
| 4035335901 | |||
| d76a838bbb | |||
| c42504beee | |||
| c6da398574 | |||
| afd30e3897 | |||
| c191a67fa7 | |||
| c63e08748c | |||
| 2400591f17 | |||
| 3e025bcf1b | |||
| 2f5529a93a | |||
| 64b4c3dfa4 | |||
| d1af2bf4c6 | |||
| 5b34c08851 | |||
| 6c660239d0 | |||
| 834c5b42b0 | |||
| 6e40d3c594 |
47
.env.example
@@ -1,29 +1,64 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Change this in every non-local environment.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
# Dev: set to * to allow all origins (credentials disabled automatically)
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Active Connection Ratio alert is only evaluated when total sessions
|
||||||
|
# are at least this number (reduces false positives on low-traffic DBs).
|
||||||
|
ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5
|
||||||
|
# Rollback Ratio tuning to reduce false positives on low traffic.
|
||||||
|
ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100
|
||||||
|
ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 80.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
# For reverse proxy + SSL prefer relative path to avoid mixed-content.
|
# 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
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
69
.github/workflows/pg-compat-matrix.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
name: PostgreSQL Compatibility Matrix
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pg-compat:
|
||||||
|
name: PG${{ matrix.pg_version }} smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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
|
||||||
398
README.md
@@ -1,113 +1,375 @@
|
|||||||
# NexaPG - PostgreSQL Monitoring Stack
|
# NexaPG
|
||||||
|
|
||||||
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
|
<p align="center">
|
||||||
|
<img src="frontend/public/nexapg-logo.svg" alt="NexaPG Logo" width="180" />
|
||||||
|
</p>
|
||||||
|
|
||||||
## Features
|
NexaPG is a full-stack PostgreSQL monitoring platform for multiple remote targets.
|
||||||
|
It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, polling collectors, query insights, alerting, and target-owner email notifications.
|
||||||
|
|
||||||
- Multi-target PostgreSQL Monitoring (remote)
|
## Table of Contents
|
||||||
- Polling Collector fuer:
|
|
||||||
- `pg_stat_database`
|
|
||||||
- `pg_stat_activity`
|
|
||||||
- `pg_stat_bgwriter`
|
|
||||||
- `pg_locks`
|
|
||||||
- `pg_stat_statements` (falls auf Target aktiviert)
|
|
||||||
- Core-DB fuer:
|
|
||||||
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
|
|
||||||
- Targets (Credentials verschluesselt via Fernet)
|
|
||||||
- Metrics / Query Stats
|
|
||||||
- Audit Logs
|
|
||||||
- Auth mit JWT Access/Refresh Tokens
|
|
||||||
- FastAPI + SQLAlchemy async + Alembic
|
|
||||||
- React (Vite) Frontend mit:
|
|
||||||
- Login/Logout
|
|
||||||
- Dashboard
|
|
||||||
- Target Detail mit Charts
|
|
||||||
- Query Insights
|
|
||||||
- Admin User Management
|
|
||||||
- Health Endpoints:
|
|
||||||
- `/api/v1/healthz`
|
|
||||||
- `/api/v1/readyz`
|
|
||||||
|
|
||||||
## Struktur
|
- [Quick Start](#quick-start)
|
||||||
|
- [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)
|
||||||
|
- [`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
|
## Highlights
|
||||||
- `frontend/` React (Vite) App
|
|
||||||
- `ops/` Scripts
|
|
||||||
- `docker-compose.yml` Stack
|
|
||||||
- `.env.example` Konfigurationsvorlage
|
|
||||||
|
|
||||||
## Schnellstart
|
- Multi-target monitoring for remote PostgreSQL instances
|
||||||
|
- Optional one-click target onboarding for "all databases" discovery on an instance
|
||||||
|
- PostgreSQL compatibility support: `14`, `15`, `16`, `17`, `18`
|
||||||
|
- JWT auth (`access` + `refresh`) and RBAC (`admin`, `operator`, `viewer`)
|
||||||
|
- Polling collector for metrics, locks, activity, and optional `pg_stat_statements`
|
||||||
|
- Target detail overview (instance, storage, replication, core performance metrics)
|
||||||
|
- Alerts system:
|
||||||
|
- standard built-in alerts
|
||||||
|
- custom SQL alerts (admin/operator)
|
||||||
|
- warning + alert severities
|
||||||
|
- real-time UI updates + toast notifications
|
||||||
|
- Target owners: alert emails are sent only to responsible users assigned to a target
|
||||||
|
- SMTP settings in admin UI (send-only) with test mail support
|
||||||
|
- Structured backend logs + audit logs
|
||||||
|
|
||||||
1. Env-Datei erstellen:
|
## UI Preview
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|

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

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

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

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

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

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

|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `backend/` FastAPI app, SQLAlchemy async models, Alembic migrations, collector services
|
||||||
|
- `frontend/` React + Vite UI
|
||||||
|
- `ops/` helper files/scripts
|
||||||
|
- `docker-compose.yml` full local stack
|
||||||
|
- `.env.example` complete environment template
|
||||||
|
- `Makefile` common commands
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine `24+`
|
||||||
|
- Docker Compose `v2+`
|
||||||
|
- GNU Make (optional but recommended)
|
||||||
|
- Open host ports (or custom values in `.env`):
|
||||||
|
- `FRONTEND_PORT` (default `5173`)
|
||||||
|
- `BACKEND_PORT` (default `8000`)
|
||||||
|
- `DB_PORT` (default `5433`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `psql` for manual DB checks
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Copy environment template:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Fernet Key setzen:
|
2. Generate a Fernet key and set `ENCRYPTION_KEY` in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
```
|
```
|
||||||
|
|
||||||
Wert in `.env` bei `ENCRYPTION_KEY` eintragen.
|
3. Start the stack:
|
||||||
|
|
||||||
3. Stack starten:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
```
|
```
|
||||||
|
|
||||||
4. URLs:
|
4. Open the application:
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
- Frontend: `http://<SERVER_IP>:<FRONTEND_PORT>`
|
||||||
- Backend API: `http://localhost:8000/api/v1`
|
- API base: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
|
||||||
- OpenAPI: `http://localhost:8000/docs`
|
- OpenAPI: `http://<SERVER_IP>:<BACKEND_PORT>/docs`
|
||||||
|
|
||||||
Default Admin (aus `.env`):
|
Initial admin bootstrap user (created from `.env` if missing):
|
||||||
- Email: `admin@example.com`
|
|
||||||
- Passwort: `ChangeMe123!`
|
|
||||||
|
|
||||||
## Commands
|
- Email: value from `INIT_ADMIN_EMAIL`
|
||||||
|
- Password: value from `INIT_ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
## Make Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up # build and start all services
|
||||||
make down
|
make down # stop all services
|
||||||
make logs
|
make logs # follow compose logs
|
||||||
make migrate
|
make migrate # optional/manual: run alembic upgrade head in backend container
|
||||||
```
|
```
|
||||||
|
|
||||||
## API (Minimum)
|
Note: Migrations run automatically when the backend container starts (`entrypoint.sh`).
|
||||||
|
|
||||||
|
## Configuration Reference (`.env`)
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `APP_NAME` | Application display name |
|
||||||
|
| `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) |
|
||||||
|
| `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||||
|
|
||||||
|
### Core Database
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
### Backend / Security
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
| `VITE_API_URL` | Frontend API base URL (build-time) |
|
||||||
|
|
||||||
|
Recommended values for `VITE_API_URL`:
|
||||||
|
|
||||||
|
- Reverse proxy setup: `/api/v1`
|
||||||
|
- Direct backend access: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
|
||||||
|
|
||||||
|
## Core Functional Areas
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
- Create, list, edit, delete targets
|
||||||
|
- Test target connection before save
|
||||||
|
- Optional "discover all databases" mode (creates one monitored target per discovered DB)
|
||||||
|
- Configure SSL mode per target
|
||||||
|
- Toggle `pg_stat_statements` usage per target
|
||||||
|
- Assign responsible users (target owners)
|
||||||
|
|
||||||
|
### Target Details
|
||||||
|
|
||||||
|
- Database Overview section with instance, role, uptime, size, replication, and core metrics
|
||||||
|
- Metric charts with range selection and live mode
|
||||||
|
- Locks and activity tables
|
||||||
|
|
||||||
|
### Query Insights
|
||||||
|
|
||||||
|
- Uses collected `pg_stat_statements` data
|
||||||
|
- Ranking and categorization views
|
||||||
|
- Search and pagination
|
||||||
|
- Disabled automatically for targets where query insights flag is off
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
|
||||||
|
- Warning and alert severity split
|
||||||
|
- Expandable alert cards with details and recommended actions
|
||||||
|
- Custom alert definitions (SQL + thresholds)
|
||||||
|
- Real-time refresh and in-app toast notifications
|
||||||
|
|
||||||
|
### Admin Settings
|
||||||
|
|
||||||
|
- User management (RBAC)
|
||||||
|
- SMTP settings for outgoing alert mails:
|
||||||
|
- enable/disable
|
||||||
|
- host/port/auth
|
||||||
|
- STARTTLS / SSL mode
|
||||||
|
- from email + from name
|
||||||
|
- recipient test mail
|
||||||
|
|
||||||
|
### Service Information
|
||||||
|
|
||||||
|
- Sidebar entry for runtime and system details
|
||||||
|
- Displays current version, latest known version, uptime, host, and platform
|
||||||
|
- "Check for Updates" against the latest published release in the official upstream repository (`git.nesterovic.cc/nessi/NexaPG`)
|
||||||
|
- Version/update source are read-only in UI (maintainer-controlled in code/release flow)
|
||||||
|
- Local displayed version is code-defined in `backend/app/core/config.py` (`NEXAPG_VERSION`) and not configurable via `.env`
|
||||||
|
|
||||||
|
## Target Owner Notifications
|
||||||
|
|
||||||
|
Email alert routing is target-specific:
|
||||||
|
|
||||||
|
- only users assigned as owners for a target receive that target's alert emails
|
||||||
|
- supports multiple owners per target
|
||||||
|
- notification sending is throttled to reduce repeated alert spam
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
- `GET /api/v1/healthz`
|
||||||
|
- `GET /api/v1/readyz`
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
- `POST /api/v1/auth/login`
|
- `POST /api/v1/auth/login`
|
||||||
- `POST /api/v1/auth/refresh`
|
- `POST /api/v1/auth/refresh`
|
||||||
- `POST /api/v1/auth/logout`
|
- `POST /api/v1/auth/logout`
|
||||||
- `GET /api/v1/me`
|
- `GET /api/v1/me`
|
||||||
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
|
|
||||||
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
|
### Targets
|
||||||
|
|
||||||
|
- `GET /api/v1/targets`
|
||||||
|
- `POST /api/v1/targets`
|
||||||
|
- `POST /api/v1/targets/test-connection`
|
||||||
|
- `GET /api/v1/targets/{id}`
|
||||||
|
- `PUT /api/v1/targets/{id}`
|
||||||
|
- `DELETE /api/v1/targets/{id}`
|
||||||
|
- `GET /api/v1/targets/{id}/owners`
|
||||||
|
- `PUT /api/v1/targets/{id}/owners`
|
||||||
|
- `GET /api/v1/targets/owner-candidates`
|
||||||
|
- `GET /api/v1/targets/{id}/metrics`
|
||||||
- `GET /api/v1/targets/{id}/locks`
|
- `GET /api/v1/targets/{id}/locks`
|
||||||
- `GET /api/v1/targets/{id}/activity`
|
- `GET /api/v1/targets/{id}/activity`
|
||||||
- `GET /api/v1/targets/{id}/top-queries`
|
- `GET /api/v1/targets/{id}/top-queries`
|
||||||
- Admin-only CRUD users:
|
- `GET /api/v1/targets/{id}/overview`
|
||||||
- `GET /api/v1/admin/users`
|
|
||||||
- `POST /api/v1/admin/users`
|
|
||||||
- `PUT /api/v1/admin/users/{user_id}`
|
|
||||||
- `DELETE /api/v1/admin/users/{user_id}`
|
|
||||||
|
|
||||||
## Security Notes
|
### Alerts
|
||||||
|
|
||||||
- Keine Secrets hardcoded
|
- `GET /api/v1/alerts/status`
|
||||||
- Passwoerter als Argon2 Hash
|
- `GET /api/v1/alerts/definitions`
|
||||||
- Target-Credentials verschluesselt (Fernet)
|
- `POST /api/v1/alerts/definitions`
|
||||||
- CORS via Env steuerbar
|
- `PUT /api/v1/alerts/definitions/{id}`
|
||||||
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
|
- `DELETE /api/v1/alerts/definitions/{id}`
|
||||||
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
|
- `POST /api/v1/alerts/definitions/test`
|
||||||
|
|
||||||
## Wichtiger Hinweis zu `pg_stat_statements`
|
### Admin
|
||||||
|
|
||||||
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
|
- `GET /api/v1/admin/users`
|
||||||
Beispiel:
|
- `POST /api/v1/admin/users`
|
||||||
|
- `PUT /api/v1/admin/users/{user_id}`
|
||||||
|
- `DELETE /api/v1/admin/users/{user_id}`
|
||||||
|
- `GET /api/v1/admin/settings/email`
|
||||||
|
- `PUT /api/v1/admin/settings/email`
|
||||||
|
- `POST /api/v1/admin/settings/email/test`
|
||||||
|
|
||||||
|
### Service Information
|
||||||
|
|
||||||
|
- `GET /api/v1/service/info`
|
||||||
|
- `POST /api/v1/service/info/check`
|
||||||
|
|
||||||
|
## `pg_stat_statements` Requirement
|
||||||
|
|
||||||
|
Query Insights requires `pg_stat_statements` on the monitored target:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If unavailable, disable it per target in target settings.
|
||||||
|
|
||||||
|
## Reverse Proxy / SSL Guidance
|
||||||
|
|
||||||
|
For production, serve frontend and API under the same public origin via reverse proxy.
|
||||||
|
|
||||||
|
- Frontend URL example: `https://monitor.example.com`
|
||||||
|
- Proxy API path `/api/` to backend service
|
||||||
|
- Use `VITE_API_URL=/api/v1`
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- Set `VITE_API_URL=/api/v1`
|
||||||
|
- Ensure proxy forwards `/api/` 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
|
||||||
|
|||||||
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")
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.routes import admin_users, auth, health, me, targets
|
from app.api.routes import admin_settings, admin_users, alerts, auth, health, me, service_info, targets
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(me.router, tags=["auth"])
|
api_router.include_router(me.router, tags=["auth"])
|
||||||
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
||||||
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
|
api_router.include_router(service_info.router, prefix="/service", tags=["service"])
|
||||||
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
||||||
|
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])
|
||||||
|
|||||||
132
backend/app/api/routes/admin_settings.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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.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="SMTP host is not configured")
|
||||||
|
if not settings.from_email:
|
||||||
|
raise HTTPException(status_code=400, detail="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=f"SMTP test failed: {exc}")
|
||||||
|
|
||||||
|
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
|
||||||
|
return {"status": "sent", "recipient": str(payload.recipient)}
|
||||||
156
backend/app/api/routes/alerts.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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.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="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="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="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="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,7 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.schemas.user import UserOut
|
from app.schemas.user import UserOut, UserPasswordChange
|
||||||
|
from app.services.audit import write_audit_log
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -9,3 +13,21 @@ router = APIRouter()
|
|||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||||
return UserOut.model_validate(user)
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/password")
|
||||||
|
async def change_password(
|
||||||
|
payload: UserPasswordChange,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if not verify_password(payload.current_password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
||||||
|
|
||||||
|
if verify_password(payload.new_password, user.password_hash):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="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,24 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, desc, select
|
from sqlalchemy import and_, delete, desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.deps import get_current_user, require_roles
|
from app.core.deps import get_current_user, require_roles
|
||||||
from app.models.models import Metric, QueryStat, Target, User
|
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
|
||||||
from app.schemas.metric import MetricOut, QueryStatOut
|
from app.schemas.metric import MetricOut, QueryStatOut
|
||||||
from app.schemas.overview import DatabaseOverviewOut
|
from app.schemas.overview import DatabaseOverviewOut
|
||||||
from app.schemas.target import TargetCreate, TargetOut, TargetUpdate
|
from app.schemas.target import (
|
||||||
|
TargetConnectionTestRequest,
|
||||||
|
TargetCreate,
|
||||||
|
TargetOut,
|
||||||
|
TargetOwnerOut,
|
||||||
|
TargetOwnersUpdate,
|
||||||
|
TargetUpdate,
|
||||||
|
)
|
||||||
from app.services.audit import write_audit_log
|
from app.services.audit import write_audit_log
|
||||||
from app.services.collector import build_target_dsn
|
from app.services.collector import build_target_dsn
|
||||||
from app.services.crypto import encrypt_secret
|
from app.services.crypto import encrypt_secret
|
||||||
@@ -17,10 +27,114 @@ from app.services.overview_service import get_target_overview
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _owners_by_target_ids(db: AsyncSession, target_ids: list[int]) -> dict[int, list[int]]:
|
||||||
|
if not target_ids:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
await db.execute(select(TargetOwner.target_id, TargetOwner.user_id).where(TargetOwner.target_id.in_(target_ids)))
|
||||||
|
).all()
|
||||||
|
mapping: dict[int, list[int]] = {target_id: [] for target_id in target_ids}
|
||||||
|
for target_id, user_id in rows:
|
||||||
|
mapping.setdefault(target_id, []).append(user_id)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _target_out_with_owners(target: Target, owner_user_ids: list[int]) -> TargetOut:
|
||||||
|
return TargetOut(
|
||||||
|
id=target.id,
|
||||||
|
name=target.name,
|
||||||
|
host=target.host,
|
||||||
|
port=target.port,
|
||||||
|
dbname=target.dbname,
|
||||||
|
username=target.username,
|
||||||
|
sslmode=target.sslmode,
|
||||||
|
use_pg_stat_statements=target.use_pg_stat_statements,
|
||||||
|
owner_user_ids=owner_user_ids,
|
||||||
|
tags=target.tags or {},
|
||||||
|
created_at=target.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_target_owners(db: AsyncSession, target_id: int, user_ids: list[int], assigned_by_user_id: int | None) -> None:
|
||||||
|
await db.execute(delete(TargetOwner).where(TargetOwner.target_id == target_id))
|
||||||
|
for user_id in sorted(set(user_ids)):
|
||||||
|
db.add(TargetOwner(target_id=target_id, user_id=user_id, assigned_by_user_id=assigned_by_user_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover_databases(payload: TargetCreate) -> list[str]:
|
||||||
|
ssl = False if payload.sslmode == "disable" else True
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
database=payload.dbname,
|
||||||
|
user=payload.username,
|
||||||
|
password=payload.password,
|
||||||
|
ssl=ssl,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT datname
|
||||||
|
FROM pg_database
|
||||||
|
WHERE datallowconn
|
||||||
|
AND NOT datistemplate
|
||||||
|
ORDER BY datname
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [row["datname"] for row in rows if row["datname"]]
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Database discovery failed: {exc}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_unique_target_name(db: AsyncSession, base_name: str) -> str:
|
||||||
|
candidate = base_name.strip()
|
||||||
|
suffix = 2
|
||||||
|
while True:
|
||||||
|
exists = await db.scalar(select(Target.id).where(Target.name == candidate))
|
||||||
|
if exists is None:
|
||||||
|
return candidate
|
||||||
|
candidate = f"{base_name} ({suffix})"
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TargetOut])
|
@router.get("", response_model=list[TargetOut])
|
||||||
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
|
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
|
||||||
|
_ = user
|
||||||
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
|
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
|
||||||
return [TargetOut.model_validate(item) for item in targets]
|
owner_map = await _owners_by_target_ids(db, [item.id for item in targets])
|
||||||
|
return [_target_out_with_owners(item, owner_map.get(item.id, [])) for item in targets]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection")
|
||||||
|
async def test_target_connection(
|
||||||
|
payload: TargetConnectionTestRequest,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
) -> dict:
|
||||||
|
_ = user
|
||||||
|
ssl = False if payload.sslmode == "disable" else True
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
database=payload.dbname,
|
||||||
|
user=payload.username,
|
||||||
|
password=payload.password,
|
||||||
|
ssl=ssl,
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
version = await conn.fetchval("SHOW server_version")
|
||||||
|
return {"ok": True, "message": "Connection successful", "server_version": version}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Connection failed: {exc}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -29,29 +143,113 @@ async def create_target(
|
|||||||
user: User = Depends(require_roles("admin", "operator")),
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> TargetOut:
|
) -> TargetOut:
|
||||||
|
owner_ids = sorted(set(payload.owner_user_ids or []))
|
||||||
|
if owner_ids:
|
||||||
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
|
||||||
|
if len(set(owners_exist)) != len(owner_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="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="No databases discovered on target")
|
||||||
|
group_id = str(uuid4())
|
||||||
|
base_tags = payload.tags or {}
|
||||||
|
for dbname in databases:
|
||||||
|
duplicate = await db.scalar(
|
||||||
|
select(Target.id).where(
|
||||||
|
Target.host == payload.host,
|
||||||
|
Target.port == payload.port,
|
||||||
|
Target.dbname == dbname,
|
||||||
|
Target.username == payload.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
continue
|
||||||
|
target_name = await _next_unique_target_name(db, f"{payload.name} / {dbname}")
|
||||||
|
tags = {
|
||||||
|
**base_tags,
|
||||||
|
"monitor_mode": "all_databases",
|
||||||
|
"monitor_group_id": group_id,
|
||||||
|
"monitor_group_name": payload.name,
|
||||||
|
}
|
||||||
target = Target(
|
target = Target(
|
||||||
name=payload.name,
|
name=target_name,
|
||||||
|
host=payload.host,
|
||||||
|
port=payload.port,
|
||||||
|
dbname=dbname,
|
||||||
|
username=payload.username,
|
||||||
|
encrypted_password=encrypted_password,
|
||||||
|
sslmode=payload.sslmode,
|
||||||
|
use_pg_stat_statements=payload.use_pg_stat_statements,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
db.add(target)
|
||||||
|
await db.flush()
|
||||||
|
created_targets.append(target)
|
||||||
|
if owner_ids:
|
||||||
|
await _set_target_owners(db, target.id, owner_ids, user.id)
|
||||||
|
|
||||||
|
if not created_targets:
|
||||||
|
raise HTTPException(status_code=400, detail="All discovered databases already exist as targets")
|
||||||
|
await db.commit()
|
||||||
|
for item in created_targets:
|
||||||
|
await db.refresh(item)
|
||||||
|
await write_audit_log(
|
||||||
|
db,
|
||||||
|
"target.create.all_databases",
|
||||||
|
user.id,
|
||||||
|
{"base_name": payload.name, "created_count": len(created_targets), "host": payload.host, "port": payload.port},
|
||||||
|
)
|
||||||
|
owner_map = await _owners_by_target_ids(db, [created_targets[0].id])
|
||||||
|
return _target_out_with_owners(created_targets[0], owner_map.get(created_targets[0].id, []))
|
||||||
|
|
||||||
|
target_name = await _next_unique_target_name(db, payload.name)
|
||||||
|
target = Target(
|
||||||
|
name=target_name,
|
||||||
host=payload.host,
|
host=payload.host,
|
||||||
port=payload.port,
|
port=payload.port,
|
||||||
dbname=payload.dbname,
|
dbname=payload.dbname,
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
encrypted_password=encrypt_secret(payload.password),
|
encrypted_password=encrypted_password,
|
||||||
sslmode=payload.sslmode,
|
sslmode=payload.sslmode,
|
||||||
|
use_pg_stat_statements=payload.use_pg_stat_statements,
|
||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
)
|
)
|
||||||
db.add(target)
|
db.add(target)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(target)
|
await db.refresh(target)
|
||||||
|
|
||||||
|
if owner_ids:
|
||||||
|
await _set_target_owners(db, target.id, owner_ids, user.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
|
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/owner-candidates", response_model=list[TargetOwnerOut])
|
||||||
|
async def list_owner_candidates(
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[TargetOwnerOut]:
|
||||||
|
_ = user
|
||||||
|
users = (await db.scalars(select(User).order_by(User.email.asc()))).all()
|
||||||
|
return [TargetOwnerOut(user_id=item.id, email=item.email, role=item.role) for item in users]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{target_id}", response_model=TargetOut)
|
@router.get("/{target_id}", response_model=TargetOut)
|
||||||
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
|
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
|
||||||
|
_ = user
|
||||||
target = await db.scalar(select(Target).where(Target.id == target_id))
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail="Target not found")
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{target_id}", response_model=TargetOut)
|
@router.put("/{target_id}", response_model=TargetOut)
|
||||||
@@ -66,14 +264,73 @@ async def update_target(
|
|||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail="Target not found")
|
||||||
|
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
owner_user_ids = updates.pop("owner_user_ids", None)
|
||||||
if "password" in updates:
|
if "password" in updates:
|
||||||
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
setattr(target, key, value)
|
setattr(target, key, value)
|
||||||
|
|
||||||
|
if owner_user_ids is not None:
|
||||||
|
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
|
||||||
|
if len(set(owners_exist)) != len(set(owner_user_ids)):
|
||||||
|
raise HTTPException(status_code=400, detail="One or more owner users were not found")
|
||||||
|
await _set_target_owners(db, target.id, owner_user_ids, user.id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(target)
|
await db.refresh(target)
|
||||||
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
|
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
|
||||||
return TargetOut.model_validate(target)
|
owner_map = await _owners_by_target_ids(db, [target.id])
|
||||||
|
return _target_out_with_owners(target, owner_map.get(target.id, []))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{target_id}/owners", response_model=list[TargetOwnerOut])
|
||||||
|
async def set_target_owners(
|
||||||
|
target_id: int,
|
||||||
|
payload: TargetOwnersUpdate,
|
||||||
|
user: User = Depends(require_roles("admin", "operator")),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[TargetOwnerOut]:
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="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="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="Target not found")
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(User.id, User.email, User.role)
|
||||||
|
.join(TargetOwner, TargetOwner.user_id == User.id)
|
||||||
|
.where(TargetOwner.target_id == target_id)
|
||||||
|
.order_by(User.email.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{target_id}")
|
@router.delete("/{target_id}")
|
||||||
@@ -161,6 +418,11 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
|
|||||||
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
|
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
|
||||||
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
|
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
|
||||||
_ = user
|
_ = user
|
||||||
|
target = await db.scalar(select(Target).where(Target.id == target_id))
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="Target not found")
|
||||||
|
if not target.use_pg_stat_statements:
|
||||||
|
return []
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
select(QueryStat)
|
select(QueryStat)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
NEXAPG_VERSION = "0.1.2"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
@@ -25,9 +27,17 @@ class Settings(BaseSettings):
|
|||||||
encryption_key: str
|
encryption_key: str
|
||||||
cors_origins: str = "http://localhost:5173"
|
cors_origins: str = "http://localhost:5173"
|
||||||
poll_interval_seconds: int = 30
|
poll_interval_seconds: int = 30
|
||||||
|
alert_active_connection_ratio_min_total_connections: int = 5
|
||||||
|
alert_rollback_ratio_window_minutes: int = 15
|
||||||
|
alert_rollback_ratio_min_total_transactions: int = 100
|
||||||
|
alert_rollback_ratio_min_rollbacks: int = 10
|
||||||
init_admin_email: str = "admin@example.com"
|
init_admin_email: str = "admin@example.com"
|
||||||
init_admin_password: str = "ChangeMe123!"
|
init_admin_password: str = "ChangeMe123!"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_version(self) -> str:
|
||||||
|
return NEXAPG_VERSION
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
from app.models.models import AuditLog, Metric, QueryStat, Target, User
|
from app.models.models import (
|
||||||
|
AlertDefinition,
|
||||||
|
AlertNotificationEvent,
|
||||||
|
AuditLog,
|
||||||
|
EmailNotificationSettings,
|
||||||
|
Metric,
|
||||||
|
QueryStat,
|
||||||
|
ServiceInfoSettings,
|
||||||
|
Target,
|
||||||
|
TargetOwner,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog"]
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Target",
|
||||||
|
"Metric",
|
||||||
|
"QueryStat",
|
||||||
|
"ServiceInfoSettings",
|
||||||
|
"AuditLog",
|
||||||
|
"AlertDefinition",
|
||||||
|
"EmailNotificationSettings",
|
||||||
|
"TargetOwner",
|
||||||
|
"AlertNotificationEvent",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.core.db import Base
|
from app.core.db import Base
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
||||||
|
owned_targets: Mapped[list["TargetOwner"]] = relationship(
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="TargetOwner.user_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Target(Base):
|
class Target(Base):
|
||||||
@@ -27,11 +32,28 @@ class Target(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
|
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
|
||||||
|
use_pg_stat_statements: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
alert_definitions: Mapped[list["AlertDefinition"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
owners: Mapped[list["TargetOwner"]] = relationship(back_populates="target", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwner(Base):
|
||||||
|
__tablename__ = "target_owners"
|
||||||
|
__table_args__ = (UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
target: Mapped[Target] = relationship(back_populates="owners")
|
||||||
|
user: Mapped[User] = relationship(foreign_keys=[user_id], back_populates="owned_targets")
|
||||||
|
|
||||||
|
|
||||||
class Metric(Base):
|
class Metric(Base):
|
||||||
@@ -73,3 +95,84 @@ class AuditLog(Base):
|
|||||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
user: Mapped[User | None] = relationship(back_populates="audit_logs")
|
user: Mapped[User | None] = relationship(back_populates="audit_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinition(Base):
|
||||||
|
__tablename__ = "alert_definitions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
target_id: Mapped[int | None] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
sql_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
comparison: Mapped[str] = mapped_column(String(10), nullable=False, default="gte")
|
||||||
|
warning_threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
alert_threshold: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotificationSettings(Base):
|
||||||
|
__tablename__ = "email_notification_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
|
||||||
|
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
warning_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
alert_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
warning_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
alert_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoSettings(Base):
|
||||||
|
__tablename__ = "service_info_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
current_version: Mapped[str] = mapped_column(String(64), nullable=False, default="0.1.0")
|
||||||
|
release_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
latest_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
update_available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_check_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNotificationEvent(Base):
|
||||||
|
__tablename__ = "alert_notification_events"
|
||||||
|
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
alert_key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||||
|
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
last_sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
56
backend/app/schemas/admin_settings.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
smtp_host: str | None
|
||||||
|
smtp_port: int
|
||||||
|
smtp_username: str | None
|
||||||
|
from_name: str | None
|
||||||
|
from_email: EmailStr | None
|
||||||
|
use_starttls: bool
|
||||||
|
use_ssl: bool
|
||||||
|
warning_subject_template: str | None
|
||||||
|
alert_subject_template: str | None
|
||||||
|
warning_body_template: str | None
|
||||||
|
alert_body_template: str | None
|
||||||
|
has_password: bool
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
clear_smtp_password: bool = False
|
||||||
|
from_name: str | None = None
|
||||||
|
from_email: EmailStr | None = None
|
||||||
|
use_starttls: bool = True
|
||||||
|
use_ssl: bool = False
|
||||||
|
warning_subject_template: str | None = None
|
||||||
|
alert_subject_template: str | None = None
|
||||||
|
warning_body_template: str | None = None
|
||||||
|
alert_body_template: str | None = None
|
||||||
|
|
||||||
|
@field_validator("smtp_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, value: int) -> int:
|
||||||
|
if value < 1 or value > 65535:
|
||||||
|
raise ValueError("smtp_port must be between 1 and 65535")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_tls_combo(self):
|
||||||
|
if self.use_starttls and self.use_ssl:
|
||||||
|
raise ValueError("use_starttls and use_ssl cannot both be true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsTestRequest(BaseModel):
|
||||||
|
recipient: EmailStr
|
||||||
|
subject: str = "NexaPG test notification"
|
||||||
|
message: str = "This is a test alert notification from NexaPG."
|
||||||
83
backend/app/schemas/alert.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionBase(BaseModel):
|
||||||
|
name: str = Field(min_length=2, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
target_id: int | None = None
|
||||||
|
sql_text: str = Field(min_length=8, max_length=4000)
|
||||||
|
comparison: str = "gte"
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionCreate(AlertDefinitionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=2, max_length=160)
|
||||||
|
description: str | None = None
|
||||||
|
target_id: int | None = None
|
||||||
|
sql_text: str | None = Field(default=None, min_length=8, max_length=4000)
|
||||||
|
comparison: str | None = None
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionOut(AlertDefinitionBase):
|
||||||
|
id: int
|
||||||
|
created_by_user_id: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionTestRequest(BaseModel):
|
||||||
|
target_id: int
|
||||||
|
sql_text: str = Field(min_length=8, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDefinitionTestResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
value: float | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatusItem(BaseModel):
|
||||||
|
alert_key: str
|
||||||
|
source: str
|
||||||
|
severity: str
|
||||||
|
category: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
target_id: int
|
||||||
|
target_name: str
|
||||||
|
value: float | None = None
|
||||||
|
warning_threshold: float | None = None
|
||||||
|
alert_threshold: float | None = None
|
||||||
|
comparison: str = "gte"
|
||||||
|
message: str
|
||||||
|
checked_at: datetime
|
||||||
|
sql_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatusResponse(BaseModel):
|
||||||
|
generated_at: datetime
|
||||||
|
warnings: list[AlertStatusItem]
|
||||||
|
alerts: list[AlertStatusItem]
|
||||||
|
warning_count: int
|
||||||
|
alert_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAlertReferenceItem(BaseModel):
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
checks: str
|
||||||
|
comparison: str
|
||||||
|
warning: str
|
||||||
|
alert: str
|
||||||
29
backend/app/schemas/service_info.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoOut(BaseModel):
|
||||||
|
app_name: str
|
||||||
|
environment: str
|
||||||
|
api_prefix: str
|
||||||
|
app_version: str
|
||||||
|
hostname: str
|
||||||
|
python_version: str
|
||||||
|
platform: str
|
||||||
|
service_started_at: datetime
|
||||||
|
uptime_seconds: int
|
||||||
|
update_source: str
|
||||||
|
latest_version: str | None
|
||||||
|
latest_ref: str | None
|
||||||
|
update_available: bool
|
||||||
|
last_checked_at: datetime | None
|
||||||
|
last_check_error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfoCheckResult(BaseModel):
|
||||||
|
latest_version: str | None
|
||||||
|
latest_ref: str | None
|
||||||
|
update_available: bool
|
||||||
|
last_checked_at: datetime
|
||||||
|
last_check_error: str | None
|
||||||
@@ -9,11 +9,23 @@ class TargetBase(BaseModel):
|
|||||||
dbname: str
|
dbname: str
|
||||||
username: str
|
username: str
|
||||||
sslmode: str = "prefer"
|
sslmode: str = "prefer"
|
||||||
|
use_pg_stat_statements: bool = True
|
||||||
|
owner_user_ids: list[int] = Field(default_factory=list)
|
||||||
tags: dict = Field(default_factory=dict)
|
tags: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class TargetCreate(TargetBase):
|
class TargetCreate(TargetBase):
|
||||||
password: str
|
password: str
|
||||||
|
discover_all_databases: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TargetConnectionTestRequest(BaseModel):
|
||||||
|
host: str
|
||||||
|
port: int = 5432
|
||||||
|
dbname: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
sslmode: str = "prefer"
|
||||||
|
|
||||||
|
|
||||||
class TargetUpdate(BaseModel):
|
class TargetUpdate(BaseModel):
|
||||||
@@ -24,6 +36,8 @@ class TargetUpdate(BaseModel):
|
|||||||
username: str | None = None
|
username: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
sslmode: str | None = None
|
sslmode: str | None = None
|
||||||
|
use_pg_stat_statements: bool | None = None
|
||||||
|
owner_user_ids: list[int] | None = None
|
||||||
tags: dict | None = None
|
tags: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -32,3 +46,13 @@ class TargetOut(TargetBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwnerOut(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class TargetOwnersUpdate(BaseModel):
|
||||||
|
user_ids: list[int] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@@ -21,3 +21,15 @@ class UserUpdate(BaseModel):
|
|||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_new_password(cls, value: str) -> str:
|
||||||
|
if len(value) < 8:
|
||||||
|
raise ValueError("new_password must be at least 8 characters")
|
||||||
|
return value
|
||||||
|
|||||||
196
backend/app/services/alert_notifications.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.models import AlertNotificationEvent, EmailNotificationSettings, TargetOwner, User
|
||||||
|
from app.schemas.alert import AlertStatusResponse
|
||||||
|
from app.services.crypto import decrypt_secret
|
||||||
|
|
||||||
|
_NOTIFICATION_COOLDOWN = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
async def _smtp_send(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
from_name: str | None,
|
||||||
|
from_email: str,
|
||||||
|
recipient: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
use_starttls: bool,
|
||||||
|
use_ssl: bool,
|
||||||
|
) -> None:
|
||||||
|
def _send() -> None:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = formataddr((from_name, from_email)) if from_name else from_email
|
||||||
|
message["To"] = recipient
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
if use_ssl:
|
||||||
|
with smtplib.SMTP_SSL(host, port, timeout=10, context=ssl.create_default_context()) as smtp:
|
||||||
|
if username:
|
||||||
|
smtp.login(username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
return
|
||||||
|
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||||
|
if use_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
if username:
|
||||||
|
smtp.login(username, password or "")
|
||||||
|
smtp.send_message(message)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_send)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_subject(item) -> str:
|
||||||
|
sev = item.severity.upper()
|
||||||
|
return f"[NexaPG][{sev}] {item.target_name} - {item.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_body(item) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Severity: {item.severity}",
|
||||||
|
f"Target: {item.target_name} (id={item.target_id})",
|
||||||
|
f"Alert: {item.name}",
|
||||||
|
f"Category: {item.category}",
|
||||||
|
f"Checked At: {item.checked_at.isoformat()}",
|
||||||
|
"",
|
||||||
|
f"Description: {item.description}",
|
||||||
|
f"Message: {item.message}",
|
||||||
|
]
|
||||||
|
if item.value is not None:
|
||||||
|
lines.append(f"Current Value: {item.value}")
|
||||||
|
if item.warning_threshold is not None:
|
||||||
|
lines.append(f"Warning Threshold: {item.warning_threshold}")
|
||||||
|
if item.alert_threshold is not None:
|
||||||
|
lines.append(f"Alert Threshold: {item.alert_threshold}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Alert Key: {item.alert_key}")
|
||||||
|
lines.append("Sent by NexaPG notification service.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_context(item) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"severity": str(item.severity),
|
||||||
|
"target_name": str(item.target_name),
|
||||||
|
"target_id": str(item.target_id),
|
||||||
|
"alert_name": str(item.name),
|
||||||
|
"category": str(item.category),
|
||||||
|
"description": str(item.description),
|
||||||
|
"message": str(item.message),
|
||||||
|
"value": "" if item.value is None else str(item.value),
|
||||||
|
"warning_threshold": "" if item.warning_threshold is None else str(item.warning_threshold),
|
||||||
|
"alert_threshold": "" if item.alert_threshold is None else str(item.alert_threshold),
|
||||||
|
"checked_at": item.checked_at.isoformat() if item.checked_at else "",
|
||||||
|
"alert_key": str(item.alert_key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_format(template: str | None, context: dict[str, str], fallback: str) -> str:
|
||||||
|
if not template:
|
||||||
|
return fallback
|
||||||
|
rendered = template
|
||||||
|
for key, value in context.items():
|
||||||
|
rendered = rendered.replace("{" + key + "}", value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
async def process_target_owner_notifications(db: AsyncSession, status: AlertStatusResponse) -> None:
|
||||||
|
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
|
||||||
|
if not settings or not settings.enabled:
|
||||||
|
return
|
||||||
|
if not settings.smtp_host or not settings.from_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
active_items = status.alerts + status.warnings
|
||||||
|
if not active_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_ids = sorted({item.target_id for item in active_items})
|
||||||
|
owner_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(TargetOwner.target_id, User.email)
|
||||||
|
.join(User, User.id == TargetOwner.user_id)
|
||||||
|
.where(TargetOwner.target_id.in_(target_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
owners_map: dict[int, set[str]] = {}
|
||||||
|
for target_id, email in owner_rows:
|
||||||
|
owners_map.setdefault(target_id, set()).add(email)
|
||||||
|
|
||||||
|
existing_rows = (
|
||||||
|
await db.scalars(
|
||||||
|
select(AlertNotificationEvent).where(
|
||||||
|
AlertNotificationEvent.target_id.in_(target_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
event_map = {(row.alert_key, row.target_id, row.severity): row for row in existing_rows}
|
||||||
|
|
||||||
|
for item in active_items:
|
||||||
|
recipients = sorted(owners_map.get(item.target_id, set()))
|
||||||
|
if not recipients:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (item.alert_key, item.target_id, item.severity)
|
||||||
|
existing = event_map.get(key)
|
||||||
|
should_send = existing is None or (now - existing.last_sent_at) >= _NOTIFICATION_COOLDOWN
|
||||||
|
|
||||||
|
if should_send:
|
||||||
|
fallback_subject = _render_subject(item)
|
||||||
|
fallback_body = _render_body(item)
|
||||||
|
context = _template_context(item)
|
||||||
|
if item.severity == "alert":
|
||||||
|
subject = _safe_format(settings.alert_subject_template, context, fallback_subject)
|
||||||
|
body = _safe_format(settings.alert_body_template, context, fallback_body)
|
||||||
|
else:
|
||||||
|
subject = _safe_format(settings.warning_subject_template, context, fallback_subject)
|
||||||
|
body = _safe_format(settings.warning_body_template, context, fallback_body)
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
await _smtp_send(
|
||||||
|
host=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username,
|
||||||
|
password=password,
|
||||||
|
from_name=settings.from_name,
|
||||||
|
from_email=settings.from_email,
|
||||||
|
recipient=recipient,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
use_starttls=settings.use_starttls,
|
||||||
|
use_ssl=settings.use_ssl,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.last_seen_at = now
|
||||||
|
if should_send:
|
||||||
|
existing.last_sent_at = now
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
AlertNotificationEvent(
|
||||||
|
alert_key=item.alert_key,
|
||||||
|
target_id=item.target_id,
|
||||||
|
severity=item.severity,
|
||||||
|
last_seen_at=now,
|
||||||
|
last_sent_at=now if should_send else now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
609
backend/app/services/alerts.py
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
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.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=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="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="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="Alert SQL must start with SELECT")
|
||||||
|
if _FORBIDDEN_SQL_WORDS.search(lowered):
|
||||||
|
raise HTTPException(status_code=400, detail="Only read-only SELECT statements are allowed")
|
||||||
|
if ";" in sql:
|
||||||
|
raise HTTPException(status_code=400, detail="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
|
||||||
@@ -13,6 +13,8 @@ import asyncpg
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
_failure_state: dict[int, dict[str, object]] = {}
|
||||||
|
_failure_log_interval_seconds = 300
|
||||||
|
|
||||||
|
|
||||||
def build_target_dsn(target: Target) -> str:
|
def build_target_dsn(target: Target) -> str:
|
||||||
@@ -41,7 +43,19 @@ async def collect_target(target: Target) -> None:
|
|||||||
try:
|
try:
|
||||||
stat_db = await conn.fetchrow(
|
stat_db = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT numbackends, xact_commit, xact_rollback, blks_hit, blks_read, tup_returned, tup_fetched
|
SELECT
|
||||||
|
numbackends,
|
||||||
|
xact_commit,
|
||||||
|
xact_rollback,
|
||||||
|
deadlocks,
|
||||||
|
temp_files,
|
||||||
|
temp_bytes,
|
||||||
|
blk_read_time,
|
||||||
|
blk_write_time,
|
||||||
|
blks_hit,
|
||||||
|
blks_read,
|
||||||
|
tup_returned,
|
||||||
|
tup_fetched
|
||||||
FROM pg_stat_database
|
FROM pg_stat_database
|
||||||
WHERE datname = current_database()
|
WHERE datname = current_database()
|
||||||
"""
|
"""
|
||||||
@@ -55,18 +69,44 @@ async def collect_target(target: Target) -> None:
|
|||||||
WHERE datname = current_database()
|
WHERE datname = current_database()
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
checkpointer_view_exists = await conn.fetchval("SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL")
|
||||||
|
bgwriter = None
|
||||||
|
if checkpointer_view_exists:
|
||||||
|
try:
|
||||||
|
bgwriter = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
num_timed AS checkpoints_timed,
|
||||||
|
num_requested AS checkpoints_req,
|
||||||
|
0::bigint AS buffers_checkpoint,
|
||||||
|
0::bigint AS buffers_clean,
|
||||||
|
0::bigint AS maxwritten_clean
|
||||||
|
FROM pg_stat_checkpointer
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
bgwriter = None
|
||||||
|
if bgwriter is None:
|
||||||
|
try:
|
||||||
bgwriter = await conn.fetchrow(
|
bgwriter = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
||||||
FROM pg_stat_bgwriter
|
FROM pg_stat_bgwriter
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
bgwriter = None
|
||||||
|
|
||||||
if stat_db is None:
|
if stat_db is None:
|
||||||
stat_db = {
|
stat_db = {
|
||||||
"numbackends": 0,
|
"numbackends": 0,
|
||||||
"xact_commit": 0,
|
"xact_commit": 0,
|
||||||
"xact_rollback": 0,
|
"xact_rollback": 0,
|
||||||
|
"deadlocks": 0,
|
||||||
|
"temp_files": 0,
|
||||||
|
"temp_bytes": 0,
|
||||||
|
"blk_read_time": 0,
|
||||||
|
"blk_write_time": 0,
|
||||||
"blks_hit": 0,
|
"blks_hit": 0,
|
||||||
"blks_read": 0,
|
"blks_read": 0,
|
||||||
"tup_returned": 0,
|
"tup_returned": 0,
|
||||||
@@ -89,6 +129,7 @@ async def collect_target(target: Target) -> None:
|
|||||||
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
|
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
|
||||||
|
|
||||||
query_rows = []
|
query_rows = []
|
||||||
|
if target.use_pg_stat_statements:
|
||||||
try:
|
try:
|
||||||
query_rows = await conn.fetch(
|
query_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
@@ -106,6 +147,13 @@ async def collect_target(target: Target) -> None:
|
|||||||
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
|
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
|
||||||
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
|
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
|
||||||
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
|
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
|
||||||
|
await _store_metric(db, target.id, "xact_commit", stat_db["xact_commit"], {})
|
||||||
|
await _store_metric(db, target.id, "xact_rollback", stat_db["xact_rollback"], {})
|
||||||
|
await _store_metric(db, target.id, "deadlocks", stat_db["deadlocks"], {})
|
||||||
|
await _store_metric(db, target.id, "temp_files", stat_db["temp_files"], {})
|
||||||
|
await _store_metric(db, target.id, "temp_bytes", stat_db["temp_bytes"], {})
|
||||||
|
await _store_metric(db, target.id, "blk_read_time", stat_db["blk_read_time"], {})
|
||||||
|
await _store_metric(db, target.id, "blk_write_time", stat_db["blk_write_time"], {})
|
||||||
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
|
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
|
||||||
await _store_metric(db, target.id, "locks_total", lock_count, {})
|
await _store_metric(db, target.id, "locks_total", lock_count, {})
|
||||||
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
|
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
|
||||||
@@ -136,8 +184,48 @@ async def collect_once() -> None:
|
|||||||
for target in targets:
|
for target in targets:
|
||||||
try:
|
try:
|
||||||
await collect_target(target)
|
await collect_target(target)
|
||||||
|
prev = _failure_state.pop(target.id, None)
|
||||||
|
if prev:
|
||||||
|
logger.info(
|
||||||
|
"collector_target_recovered target=%s after_failures=%s last_error=%s",
|
||||||
|
target.id,
|
||||||
|
prev.get("count", 0),
|
||||||
|
prev.get("error"),
|
||||||
|
)
|
||||||
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
current_error = str(exc)
|
||||||
|
state = _failure_state.get(target.id)
|
||||||
|
if state is None:
|
||||||
|
_failure_state[target.id] = {
|
||||||
|
"count": 1,
|
||||||
|
"last_log_at": now,
|
||||||
|
"error": current_error,
|
||||||
|
}
|
||||||
logger.exception("collector_error target=%s err=%s", target.id, exc)
|
logger.exception("collector_error target=%s err=%s", target.id, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
count = int(state.get("count", 0)) + 1
|
||||||
|
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.error(
|
||||||
|
"collector_error_throttled target=%s err=%s consecutive_failures=%s",
|
||||||
|
target.id,
|
||||||
|
current_error,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def collector_loop(stop_event: asyncio.Event) -> None:
|
async def collector_loop(stop_event: asyncio.Event) -> None:
|
||||||
|
|||||||
@@ -106,6 +106,25 @@ async def collect_overview(
|
|||||||
errors,
|
errors,
|
||||||
"pg_stat_database_perf",
|
"pg_stat_database_perf",
|
||||||
)
|
)
|
||||||
|
checkpointer_view_exists = await _safe_fetchval(
|
||||||
|
conn,
|
||||||
|
"SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL",
|
||||||
|
errors,
|
||||||
|
"checkpointer_view_exists",
|
||||||
|
)
|
||||||
|
if checkpointer_view_exists:
|
||||||
|
bgwriter = await _safe_fetchrow(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
num_timed AS checkpoints_timed,
|
||||||
|
num_requested AS checkpoints_req
|
||||||
|
FROM pg_stat_checkpointer
|
||||||
|
""",
|
||||||
|
errors,
|
||||||
|
"pg_stat_checkpointer",
|
||||||
|
)
|
||||||
|
else:
|
||||||
bgwriter = await _safe_fetchrow(
|
bgwriter = await _safe_fetchrow(
|
||||||
conn,
|
conn,
|
||||||
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",
|
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",
|
||||||
|
|||||||
91
backend/app/services/service_info.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
UPSTREAM_REPO_WEB = "https://git.nesterovic.cc/nessi/NexaPG"
|
||||||
|
UPSTREAM_REPO_API = "https://git.nesterovic.cc/api/v1/repos/nessi/NexaPG"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_version(payload: str) -> str | None:
|
||||||
|
txt = payload.strip()
|
||||||
|
if not txt:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(txt)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key in ("latest_version", "version", "tag_name", "name"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
for key in ("latest_version", "version", "tag_name", "name"):
|
||||||
|
value = data[0].get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
first_line = txt.splitlines()[0].strip()
|
||||||
|
if first_line:
|
||||||
|
return first_line[:64]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_semver(value: str) -> tuple[int, ...] | None:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized.startswith("v"):
|
||||||
|
normalized = normalized[1:]
|
||||||
|
parts = re.findall(r"\d+", normalized)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
return tuple(int(p) for p in parts[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def is_update_available(current_version: str, latest_version: str) -> bool:
|
||||||
|
current = _parse_semver(current_version)
|
||||||
|
latest = _parse_semver(latest_version)
|
||||||
|
if current and latest:
|
||||||
|
max_len = max(len(current), len(latest))
|
||||||
|
current = current + (0,) * (max_len - len(current))
|
||||||
|
latest = latest + (0,) * (max_len - len(latest))
|
||||||
|
return latest > current
|
||||||
|
return latest_version.strip() != current_version.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str):
|
||||||
|
req = Request(url, headers={"User-Agent": "NexaPG/1.0"})
|
||||||
|
with urlopen(req, timeout=8) as response:
|
||||||
|
raw = response.read(64_000).decode("utf-8", errors="replace")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_latest_from_upstream_sync() -> tuple[str, str]:
|
||||||
|
latest_release_url = f"{UPSTREAM_REPO_API}/releases/latest"
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = _get_json(latest_release_url)
|
||||||
|
if isinstance(release, dict):
|
||||||
|
tag = (release.get("tag_name") or release.get("name") or "").strip()
|
||||||
|
if tag:
|
||||||
|
return tag[:64], "release"
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Could not fetch latest release from upstream repository: {exc}") from exc
|
||||||
|
raise ValueError("No published release found in upstream repository")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_latest_from_upstream() -> tuple[str | None, str | None, str | None]:
|
||||||
|
try:
|
||||||
|
latest, ref = await asyncio.to_thread(_fetch_latest_from_upstream_sync)
|
||||||
|
return latest, ref, None
|
||||||
|
except URLError as exc:
|
||||||
|
return None, None, f"Version check failed: {exc.reason}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, None, f"Version check failed: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
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
|
||||||
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 |
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>NexaPG Monitor</title>
|
<title>NexaPG Monitor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
2285
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 172 KiB |
2285
frontend/public/nexapg-logo.svg
Normal file
|
After Width: | Height: | Size: 172 KiB |
@@ -1,12 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "./state";
|
import { useAuth } from "./state";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { TargetsPage } from "./pages/TargetsPage";
|
import { TargetsPage } from "./pages/TargetsPage";
|
||||||
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
||||||
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
||||||
|
import { AlertsPage } from "./pages/AlertsPage";
|
||||||
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||||
|
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
|
||||||
|
import { UserSettingsPage } from "./pages/UserSettingsPage";
|
||||||
|
|
||||||
function Protected({ children }) {
|
function Protected({ children }) {
|
||||||
const { tokens } = useAuth();
|
const { tokens } = useAuth();
|
||||||
@@ -16,40 +19,134 @@ function Protected({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ children }) {
|
function Layout({ children }) {
|
||||||
const { me, logout } = useAuth();
|
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<img src="/nexapg-logo.svg" alt="NexaPG" className="brand-logo" />
|
||||||
<h1>NexaPG</h1>
|
<h1>NexaPG</h1>
|
||||||
|
</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<NavLink to="/" end className={navClass}>
|
<NavLink to="/" end className={navClass}>
|
||||||
<span className="nav-icon">DB</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 6c0 1.7 3.6 3 8 3s8-1.3 8-3M4 18c0 1.7 3.6 3 8 3s8-1.3 8-3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Dashboard</span>
|
<span className="nav-label">Dashboard</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/targets" className={navClass}>
|
<NavLink to="/targets" className={navClass}>
|
||||||
<span className="nav-icon">TG</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3zM12 12l8-4.5M12 12L4 7.5M12 12v9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Targets</span>
|
<span className="nav-label">Targets</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/query-insights" className={navClass}>
|
<NavLink to="/query-insights" className={navClass}>
|
||||||
<span className="nav-icon">QI</span>
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M4 19h16M7 15l3-3 3 2 4-5M18 8h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Query Insights</span>
|
<span className="nav-label">Query Insights</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/alerts" className={navClass}>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V10a6 6 0 0 0-12 0v4.2a2 2 0 0 1-.6 1.4L4 17h5m6 0a3 3 0 0 1-6 0" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Alerts</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/service-info"
|
||||||
|
className={({ isActive }) => `nav-btn${isActive ? " active" : ""}${serviceUpdateAvailable ? " update-available" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm0-11v6m0-10h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Service Information</span>
|
||||||
|
</NavLink>
|
||||||
{me?.role === "admin" && (
|
{me?.role === "admin" && (
|
||||||
<NavLink to="/admin/users" className={navClass}>
|
<>
|
||||||
<span className="nav-icon">AD</span>
|
<div className="sidebar-nav-spacer" aria-hidden="true" />
|
||||||
|
<NavLink to="/admin/users" className={({ isActive }) => `nav-btn admin-nav${isActive ? " active" : ""}`}>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm-7 8a7 7 0 0 1 14 0" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span className="nav-label">Admin</span>
|
<span className="nav-label">Admin</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
|
<div 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>{me?.email}</div>
|
<div>{me?.email}</div>
|
||||||
<div className="role">{me?.role}</div>
|
<div className="role">{me?.role}</div>
|
||||||
|
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
|
||||||
|
User Settings
|
||||||
|
</NavLink>
|
||||||
<button className="logout-btn" onClick={logout}>Logout</button>
|
<button className="logout-btn" onClick={logout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">{children}</main>
|
<main className="main">
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" aria-live="polite" aria-atomic="true">
|
||||||
|
{alertToasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}${toast.closing ? " closing" : ""}`}>
|
||||||
|
<div className="alert-toast-head">
|
||||||
|
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
|
||||||
|
<div className="toast-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toast-view"
|
||||||
|
title="Open in Alerts"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/alerts?open=${encodeURIComponent(toast.alertKey || "")}`);
|
||||||
|
dismissAlertToast(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path
|
||||||
|
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="alert-toast-title">{toast.title}</div>
|
||||||
|
<div className="alert-toast-target">{toast.target}</div>
|
||||||
|
<div className="alert-toast-message">{toast.message}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,6 +165,9 @@ export function App() {
|
|||||||
<Route path="/targets" element={<TargetsPage />} />
|
<Route path="/targets" element={<TargetsPage />} />
|
||||||
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
||||||
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
||||||
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
|
<Route path="/service-info" element={<ServiceInfoPage />} />
|
||||||
|
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -2,21 +2,78 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const TEMPLATE_VARIABLES = [
|
||||||
|
"target_name",
|
||||||
|
"target_id",
|
||||||
|
"alert_name",
|
||||||
|
"severity",
|
||||||
|
"category",
|
||||||
|
"description",
|
||||||
|
"message",
|
||||||
|
"value",
|
||||||
|
"warning_threshold",
|
||||||
|
"alert_threshold",
|
||||||
|
"checked_at",
|
||||||
|
"alert_key",
|
||||||
|
];
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const { tokens, refresh, me } = useAuth();
|
const { tokens, refresh, me } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
|
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
|
||||||
|
const [emailSettings, setEmailSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
smtp_host: "",
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_name: "",
|
||||||
|
from_email: "",
|
||||||
|
use_starttls: true,
|
||||||
|
use_ssl: false,
|
||||||
|
warning_subject_template: "",
|
||||||
|
alert_subject_template: "",
|
||||||
|
warning_body_template: "",
|
||||||
|
alert_body_template: "",
|
||||||
|
});
|
||||||
|
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
|
||||||
|
const [testRecipient, setTestRecipient] = useState("");
|
||||||
|
const [smtpInfo, setSmtpInfo] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
|
const [userRows, smtp] = await Promise.all([
|
||||||
|
apiFetch("/admin/users", {}, tokens, refresh),
|
||||||
|
apiFetch("/admin/settings/email", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setUsers(userRows);
|
||||||
|
setEmailSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!smtp.enabled,
|
||||||
|
smtp_host: smtp.smtp_host || "",
|
||||||
|
smtp_port: smtp.smtp_port || 587,
|
||||||
|
smtp_username: smtp.smtp_username || "",
|
||||||
|
smtp_password: "",
|
||||||
|
clear_smtp_password: false,
|
||||||
|
from_name: smtp.from_name || "",
|
||||||
|
from_email: smtp.from_email || "",
|
||||||
|
use_starttls: !!smtp.use_starttls,
|
||||||
|
use_ssl: !!smtp.use_ssl,
|
||||||
|
warning_subject_template: smtp.warning_subject_template || "",
|
||||||
|
alert_subject_template: smtp.alert_subject_template || "",
|
||||||
|
warning_body_template: smtp.warning_body_template || "",
|
||||||
|
alert_body_template: smtp.alert_body_template || "",
|
||||||
|
}));
|
||||||
|
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
|
||||||
|
setTestRecipient(smtp.from_email || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
||||||
}, [me]);
|
}, [me]);
|
||||||
|
|
||||||
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
|
if (me?.role !== "admin") return <div className="card">Admins only.</div>;
|
||||||
|
|
||||||
const create = async (e) => {
|
const create = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -38,26 +95,104 @@ export function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSmtp = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...emailSettings,
|
||||||
|
smtp_host: emailSettings.smtp_host.trim() || null,
|
||||||
|
smtp_username: emailSettings.smtp_username.trim() || null,
|
||||||
|
from_name: emailSettings.from_name.trim() || null,
|
||||||
|
from_email: emailSettings.from_email.trim() || null,
|
||||||
|
smtp_password: emailSettings.smtp_password || null,
|
||||||
|
warning_subject_template: emailSettings.warning_subject_template.trim() || null,
|
||||||
|
alert_subject_template: emailSettings.alert_subject_template.trim() || null,
|
||||||
|
warning_body_template: emailSettings.warning_body_template.trim() || null,
|
||||||
|
alert_body_template: emailSettings.alert_body_template.trim() || null,
|
||||||
|
};
|
||||||
|
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
setSmtpInfo("SMTP settings saved.");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTestMail = async () => {
|
||||||
|
setError("");
|
||||||
|
setSmtpInfo("");
|
||||||
|
try {
|
||||||
|
const recipient = testRecipient.trim();
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error("Please enter a test recipient email.");
|
||||||
|
}
|
||||||
|
await apiFetch(
|
||||||
|
"/admin/settings/email/test",
|
||||||
|
{ method: "POST", body: JSON.stringify({ recipient }) },
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setSmtpInfo(`Test email sent to ${recipient}.`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const protocolMode = emailSettings.use_ssl ? "ssl" : emailSettings.use_starttls ? "starttls" : "plain";
|
||||||
|
const setProtocolMode = (mode) => {
|
||||||
|
if (mode === "ssl") {
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: true, use_starttls: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "starttls") {
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="admin-settings-page">
|
||||||
<h2>Admin Users</h2>
|
<h2>Admin Settings</h2>
|
||||||
|
<p className="muted admin-page-subtitle">Manage users and outgoing notifications for this NexaPG instance.</p>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
<form className="card grid three" onSubmit={create}>
|
|
||||||
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
<div className="card">
|
||||||
|
<div className="admin-section-head">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<p className="muted">Create accounts and manage access roles.</p>
|
||||||
|
</div>
|
||||||
|
<form className="grid three admin-user-form" onSubmit={create}>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
placeholder="passwort"
|
placeholder="Set initial password"
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Role</label>
|
||||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
<option value="viewer">viewer</option>
|
<option value="viewer">viewer</option>
|
||||||
<option value="operator">operator</option>
|
<option value="operator">operator</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
</select>
|
</select>
|
||||||
<button>User anlegen</button>
|
</div>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit">Create User</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className="card">
|
</div>
|
||||||
|
|
||||||
|
<div className="card admin-users-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,16 +204,214 @@ export function AdminUsersPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id} className="admin-user-row">
|
||||||
<td>{u.id}</td>
|
<td className="user-col-id">{u.id}</td>
|
||||||
<td>{u.email}</td>
|
<td className="user-col-email">{u.email}</td>
|
||||||
<td>{u.role}</td>
|
<td>
|
||||||
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
|
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.id !== me.id && (
|
||||||
|
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12">
|
||||||
|
<path
|
||||||
|
d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="admin-section-head">
|
||||||
|
<h3>Alert Email Notifications (SMTP)</h3>
|
||||||
|
<p className="muted">Configure send-only SMTP for warning and alert notifications.</p>
|
||||||
|
</div>
|
||||||
|
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
|
||||||
|
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
|
||||||
|
<div className="admin-subcard field-full">
|
||||||
|
<h4>SMTP Settings</h4>
|
||||||
|
<div className="grid two">
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.enabled}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>
|
||||||
|
<strong>Enable alert emails</strong>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP host</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_host}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={emailSettings.smtp_port}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP username</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.smtp_username}
|
||||||
|
placeholder="alerts@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>SMTP password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={emailSettings.smtp_password}
|
||||||
|
placeholder={smtpState.has_password ? "Stored (enter to replace)" : "Set password"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>From name</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.from_name}
|
||||||
|
placeholder="NexaPG Alerts"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>From email</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.from_email}
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Transport mode</label>
|
||||||
|
<div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "starttls" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "starttls"}
|
||||||
|
onClick={() => setProtocolMode("starttls")}
|
||||||
|
>
|
||||||
|
STARTTLS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "ssl" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "ssl"}
|
||||||
|
onClick={() => setProtocolMode("ssl")}
|
||||||
|
>
|
||||||
|
SSL/TLS (SMTPS)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`smtp-mode-btn ${protocolMode === "plain" ? "active" : ""}`}
|
||||||
|
aria-pressed={protocolMode === "plain"}
|
||||||
|
onClick={() => setProtocolMode("plain")}
|
||||||
|
>
|
||||||
|
No TLS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted">Select exactly one mode to avoid STARTTLS/SSL conflicts.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="toggle-check field-full">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={emailSettings.clear_smtp_password}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, clear_smtp_password: e.target.checked, smtp_password: e.target.checked ? "" : emailSettings.smtp_password })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" />
|
||||||
|
<span>Clear stored SMTP password</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-subcard field-full">
|
||||||
|
<h4>Template Settings</h4>
|
||||||
|
<p className="muted template-help-text">
|
||||||
|
If a template field is left empty, NexaPG automatically uses the built-in default template.
|
||||||
|
</p>
|
||||||
|
<div className="template-vars-grid">
|
||||||
|
{TEMPLATE_VARIABLES.map((item) => (
|
||||||
|
<code key={item} className="template-var-pill">
|
||||||
|
{"{" + item + "}"}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Warning subject template</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.warning_subject_template}
|
||||||
|
placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Alert subject template</label>
|
||||||
|
<input
|
||||||
|
value={emailSettings.alert_subject_template}
|
||||||
|
placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Warning body template</label>
|
||||||
|
<textarea
|
||||||
|
value={emailSettings.warning_body_template}
|
||||||
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nChecked At: {checked_at}"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, warning_body_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Alert body template</label>
|
||||||
|
<textarea
|
||||||
|
value={emailSettings.alert_body_template}
|
||||||
|
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nCurrent Value: {value}\nWarning Threshold: {warning_threshold}\nAlert Threshold: {alert_threshold}\nChecked At: {checked_at}\nAlert Key: {alert_key}"}
|
||||||
|
onChange={(e) => setEmailSettings({ ...emailSettings, alert_body_template: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<input
|
||||||
|
className="admin-test-recipient"
|
||||||
|
value={testRecipient}
|
||||||
|
placeholder="test recipient email"
|
||||||
|
onChange={(e) => setTestRecipient(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="secondary-btn" type="button" onClick={sendTestMail}>Send Test Mail</button>
|
||||||
|
<button className="primary-btn" type="submit">Save SMTP Settings</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted field-full">
|
||||||
|
Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"}
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
524
frontend/src/pages/AlertsPage.jsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
target_id: "",
|
||||||
|
sql_text: "SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'",
|
||||||
|
comparison: "gte",
|
||||||
|
warning_threshold: "",
|
||||||
|
alert_threshold: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAlertValue(value) {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (Number.isInteger(value)) return String(value);
|
||||||
|
return Number(value).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(ts) {
|
||||||
|
if (!ts) return "-";
|
||||||
|
return new Date(ts).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlertSuggestions(item) {
|
||||||
|
const name = (item?.name || "").toLowerCase();
|
||||||
|
const category = (item?.category || "").toLowerCase();
|
||||||
|
const source = (item?.source || "").toLowerCase();
|
||||||
|
const value = Number(item?.value || 0);
|
||||||
|
const suggestions = [];
|
||||||
|
|
||||||
|
if (name.includes("reachability") || name.includes("connectivity") || category === "availability") {
|
||||||
|
suggestions.push("Verify host, port, firewall rules, and network routing between backend container and DB target.");
|
||||||
|
suggestions.push("Check PostgreSQL `listen_addresses` and `pg_hba.conf` on the monitored instance.");
|
||||||
|
}
|
||||||
|
if (name.includes("freshness") || item?.message?.toLowerCase().includes("no metrics")) {
|
||||||
|
suggestions.push("Check collector logs and polling interval. Confirm the target credentials are still valid.");
|
||||||
|
suggestions.push("Run a manual connection test in Targets Management and verify SSL mode.");
|
||||||
|
}
|
||||||
|
if (name.includes("cache hit") || category === "performance") {
|
||||||
|
suggestions.push("Inspect slow queries and add/adjust indexes for frequent WHERE/JOIN columns.");
|
||||||
|
suggestions.push("Review shared buffers and query patterns that cause high disk reads.");
|
||||||
|
}
|
||||||
|
if (name.includes("lock") || category === "contention") {
|
||||||
|
suggestions.push("Inspect blocking sessions in `pg_stat_activity` and long transactions.");
|
||||||
|
suggestions.push("Reduce transaction scope/duration and add missing indexes to avoid lock escalation.");
|
||||||
|
}
|
||||||
|
if (name.includes("deadlock")) {
|
||||||
|
suggestions.push("Enforce a consistent table access order in transactions to prevent deadlocks.");
|
||||||
|
suggestions.push("Retry deadlocked transactions in the application with backoff.");
|
||||||
|
}
|
||||||
|
if (name.includes("checkpoint") || category === "io") {
|
||||||
|
suggestions.push("Review `max_wal_size`, `checkpoint_timeout`, and write burst patterns.");
|
||||||
|
suggestions.push("Check disk throughput and WAL pressure during peak load.");
|
||||||
|
}
|
||||||
|
if (name.includes("rollback")) {
|
||||||
|
suggestions.push("Investigate application errors causing transaction rollbacks.");
|
||||||
|
suggestions.push("Validate constraints/input earlier to reduce failed writes.");
|
||||||
|
}
|
||||||
|
if (name.includes("query") || category === "query" || source === "custom") {
|
||||||
|
suggestions.push("Run `EXPLAIN (ANALYZE, BUFFERS)` for the affected query and optimize highest-cost nodes.");
|
||||||
|
suggestions.push("Prioritize fixes for high total-time queries first, then high mean-time queries.");
|
||||||
|
}
|
||||||
|
if (value > 0 && item?.comparison && item?.alert_threshold !== null && item?.alert_threshold !== undefined) {
|
||||||
|
suggestions.push(`Current value is ${value.toFixed(2)} with threshold rule ${item.comparison} ${Number(item.alert_threshold).toFixed(2)}.`);
|
||||||
|
}
|
||||||
|
if (!suggestions.length) {
|
||||||
|
suggestions.push("Start with target activity, locks, and query insights to isolate the root cause.");
|
||||||
|
suggestions.push("Compare current values to the last stable period and tune threshold sensitivity if needed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { tokens, refresh, me, alertStatus } = useAuth();
|
||||||
|
const [targets, setTargets] = useState([]);
|
||||||
|
const [definitions, setDefinitions] = useState([]);
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [expandedKey, setExpandedKey] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [standardReference, setStandardReference] = useState([]);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const canManageAlerts = me?.role === "admin" || me?.role === "operator";
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const [targetRows, referenceRows] = await Promise.all([
|
||||||
|
apiFetch("/targets", {}, tokens, refresh),
|
||||||
|
apiFetch("/alerts/standard-reference", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setTargets(targetRows);
|
||||||
|
setStandardReference(Array.isArray(referenceRows) ? referenceRows : []);
|
||||||
|
|
||||||
|
if (canManageAlerts) {
|
||||||
|
const defs = await apiFetch("/alerts/definitions", {}, tokens, refresh);
|
||||||
|
setDefinitions(defs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll();
|
||||||
|
}, [canManageAlerts]);
|
||||||
|
|
||||||
|
const targetOptions = useMemo(
|
||||||
|
() => [{ id: "", name: "All targets" }, ...targets.map((t) => ({ id: String(t.id), name: `${t.name} (${t.host}:${t.port})` }))],
|
||||||
|
[targets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createDefinition = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setTestResult("");
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
"/alerts/definitions",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || null,
|
||||||
|
target_id: form.target_id ? Number(form.target_id) : null,
|
||||||
|
sql_text: form.sql_text,
|
||||||
|
comparison: form.comparison,
|
||||||
|
warning_threshold: form.warning_threshold === "" ? null : Number(form.warning_threshold),
|
||||||
|
alert_threshold: Number(form.alert_threshold),
|
||||||
|
enabled: !!form.enabled,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setForm(initialForm);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDefinition = async () => {
|
||||||
|
if (!form.target_id) {
|
||||||
|
setTestResult("Select a specific target to test this SQL query.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult("");
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(
|
||||||
|
"/alerts/definitions/test",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
target_id: Number(form.target_id),
|
||||||
|
sql_text: form.sql_text,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
setTestResult(`Query test succeeded. Returned value: ${formatAlertValue(res.value)}`);
|
||||||
|
} else {
|
||||||
|
setTestResult(`Query test failed: ${res.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDefinition = async (definitionId) => {
|
||||||
|
if (!confirm("Delete this custom alert definition?")) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/alerts/definitions/${definitionId}`, { method: "DELETE" }, tokens, refresh);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDefinition = async (definition) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
`/alerts/definitions/${definition.id}`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ enabled: !definition.enabled }) },
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = (key) => {
|
||||||
|
setExpandedKey((prev) => (prev === key ? "" : key));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const openKey = params.get("open");
|
||||||
|
if (!openKey) return;
|
||||||
|
setExpandedKey(openKey);
|
||||||
|
const id = `alert-item-${openKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 120);
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
if (loading) return <div className="card">Loading alerts...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alerts-page">
|
||||||
|
<h2>Alerts</h2>
|
||||||
|
<p className="alerts-subtitle">Warnings are early signals. Alerts are critical thresholds reached or exceeded.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid two alerts-kpis">
|
||||||
|
<div className="card alerts-kpi warning">
|
||||||
|
<strong>{alertStatus.warning_count || 0}</strong>
|
||||||
|
<span>Warnings</span>
|
||||||
|
</div>
|
||||||
|
<div className="card alerts-kpi alert">
|
||||||
|
<strong>{alertStatus.alert_count || 0}</strong>
|
||||||
|
<span>Alerts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<section className="card">
|
||||||
|
<h3>Warnings</h3>
|
||||||
|
{alertStatus.warnings?.length ? (
|
||||||
|
<div className="alerts-list">
|
||||||
|
{alertStatus.warnings.map((item) => {
|
||||||
|
const isOpen = expandedKey === item.alert_key;
|
||||||
|
const suggestions = buildAlertSuggestions(item);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`alert-item warning ${isOpen ? "is-open" : ""}`}
|
||||||
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="alert-item-head">
|
||||||
|
<span className="alert-badge warning">Warning</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.target_name}</small>
|
||||||
|
</div>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="alert-message">{item.message}</p>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="alert-details-grid">
|
||||||
|
<div><span>Source</span><strong>{item.source}</strong></div>
|
||||||
|
<div><span>Category</span><strong>{item.category}</strong></div>
|
||||||
|
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
|
||||||
|
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
|
||||||
|
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
|
||||||
|
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
|
||||||
|
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
|
||||||
|
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
|
||||||
|
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
|
||||||
|
<div className="alert-suggestions">
|
||||||
|
<h4>Recommended actions</h4>
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No warning-level alerts right now.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h3>Alerts</h3>
|
||||||
|
{alertStatus.alerts?.length ? (
|
||||||
|
<div className="alerts-list">
|
||||||
|
{alertStatus.alerts.map((item) => {
|
||||||
|
const isOpen = expandedKey === item.alert_key;
|
||||||
|
const suggestions = buildAlertSuggestions(item);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`alert-item alert ${isOpen ? "is-open" : ""}`}
|
||||||
|
key={item.alert_key}
|
||||||
|
id={`alert-item-${item.alert_key.replace(/[^a-zA-Z0-9_-]/g, "-")}`}
|
||||||
|
onClick={() => toggleExpanded(item.alert_key)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="alert-item-head">
|
||||||
|
<span className="alert-badge alert">Alert</span>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<small>{item.target_name}</small>
|
||||||
|
</div>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="alert-message">{item.message}</p>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="alert-details-grid">
|
||||||
|
<div><span>Source</span><strong>{item.source}</strong></div>
|
||||||
|
<div><span>Category</span><strong>{item.category}</strong></div>
|
||||||
|
<div><span>Current Value</span><strong>{formatAlertValue(item.value)}</strong></div>
|
||||||
|
<div><span>Comparison</span><strong>{item.comparison}</strong></div>
|
||||||
|
<div><span>Warning Threshold</span><strong>{formatAlertValue(item.warning_threshold)}</strong></div>
|
||||||
|
<div><span>Alert Threshold</span><strong>{formatAlertValue(item.alert_threshold)}</strong></div>
|
||||||
|
<div><span>Checked At</span><strong>{formatTs(item.checked_at)}</strong></div>
|
||||||
|
<div><span>Target ID</span><strong>{item.target_id}</strong></div>
|
||||||
|
{item.sql_text && <div className="alert-sql"><code>{item.sql_text}</code></div>}
|
||||||
|
<div className="alert-suggestions">
|
||||||
|
<h4>Recommended actions</h4>
|
||||||
|
<ul>
|
||||||
|
{suggestions.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No critical alerts right now.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Standard Alert Reference</h3>
|
||||||
|
<p>What each built-in alert checks and which default thresholds are used.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<div className="standard-alerts-note">
|
||||||
|
Some rules include traffic guards to reduce noise (for example rollback ratio needs enough transactions).
|
||||||
|
</div>
|
||||||
|
<div className="standard-alerts-table-wrap">
|
||||||
|
<table className="standard-alerts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Checks</th>
|
||||||
|
<th>Comparison</th>
|
||||||
|
<th>Warning</th>
|
||||||
|
<th>Alert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{standardReference.length > 0 ? (
|
||||||
|
standardReference.map((row) => (
|
||||||
|
<tr key={row.key || row.name}>
|
||||||
|
<td>{row.name}</td>
|
||||||
|
<td>{row.checks}</td>
|
||||||
|
<td><code>{row.comparison}</code></td>
|
||||||
|
<td>{row.warning}</td>
|
||||||
|
<td>{row.alert}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="muted">No standard alert metadata available.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{canManageAlerts && (
|
||||||
|
<>
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Create Custom Alert</h3>
|
||||||
|
<p>Admins and operators can add SQL-based checks with warning and alert thresholds.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<form className="alert-form grid two" onSubmit={createDefinition}>
|
||||||
|
<div className="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. High Active Sessions" required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Target Scope</label>
|
||||||
|
<select value={form.target_id} onChange={(e) => setForm({ ...form, target_id: e.target.value })}>
|
||||||
|
{targetOptions.map((opt) => (
|
||||||
|
<option key={opt.id || "all"} value={opt.id}>
|
||||||
|
{opt.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Comparison</label>
|
||||||
|
<select value={form.comparison} onChange={(e) => setForm({ ...form, comparison: e.target.value })}>
|
||||||
|
<option value="gte">greater than or equal (>=)</option>
|
||||||
|
<option value="gt">greater than (>)</option>
|
||||||
|
<option value="lte">less than or equal (<=)</option>
|
||||||
|
<option value="lt">less than (<)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this check validate?" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Warning Threshold (optional)</label>
|
||||||
|
<input type="number" step="any" value={form.warning_threshold} onChange={(e) => setForm({ ...form, warning_threshold: e.target.value })} placeholder="e.g. 20" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Alert Threshold</label>
|
||||||
|
<input type="number" step="any" value={form.alert_threshold} onChange={(e) => setForm({ ...form, alert_threshold: e.target.value })} placeholder="e.g. 50" required />
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>SQL Query (must return one numeric value)</label>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.sql_text}
|
||||||
|
onChange={(e) => setForm({ ...form, sql_text: e.target.value })}
|
||||||
|
placeholder="SELECT count(*)::float FROM pg_stat_activity WHERE state = 'active'"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="alert-form-actions field-full">
|
||||||
|
<button type="button" className="secondary-btn" onClick={testDefinition} disabled={testing}>
|
||||||
|
{testing ? "Testing..." : "Test query output"}
|
||||||
|
</button>
|
||||||
|
<button className="primary-btn" disabled={saving}>
|
||||||
|
{saving ? "Creating..." : "Create custom alert"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testResult && <div className="test-connection-result">{testResult}</div>}
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Custom Alert Definitions</h3>
|
||||||
|
<p>All saved SQL-based alert rules.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
{definitions.length ? (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>Comparison</th>
|
||||||
|
<th>Warn</th>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{definitions.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>{d.name}</td>
|
||||||
|
<td>{d.target_id ? targets.find((t) => t.id === d.target_id)?.name || `Target #${d.target_id}` : "All targets"}</td>
|
||||||
|
<td>{d.comparison}</td>
|
||||||
|
<td>{d.warning_threshold ?? "-"}</td>
|
||||||
|
<td>{d.alert_threshold}</td>
|
||||||
|
<td>{d.enabled ? "Enabled" : "Disabled"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" className={`table-action-btn toggle ${d.enabled ? "enabled" : "disabled"}`} onClick={() => toggleDefinition(d)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M7 12h10M12 7v10" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{d.enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="table-action-btn delete" onClick={() => removeDefinition(d.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No custom alerts created yet.</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,20 @@ import { Link } from "react-router-dom";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
function getTargetGroupMeta(target) {
|
||||||
|
const tags = target?.tags || {};
|
||||||
|
if (tags.monitor_mode !== "all_databases" || !tags.monitor_group_id) return null;
|
||||||
|
return {
|
||||||
|
id: tags.monitor_group_id,
|
||||||
|
name: tags.monitor_group_name || target.name || "All databases",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { tokens, refresh } = useAuth();
|
const { tokens, refresh, alertStatus } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [openGroups, setOpenGroups] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -13,8 +24,10 @@ export function DashboardPage() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch("/targets", {}, tokens, refresh);
|
const targetRows = await apiFetch("/targets", {}, tokens, refresh);
|
||||||
if (active) setTargets(data);
|
if (active) {
|
||||||
|
setTargets(targetRows);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -26,48 +39,172 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
}, [tokens, refresh]);
|
}, [tokens, refresh]);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Dashboard...</div>;
|
if (loading) return <div className="card">Loading dashboard...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
|
const targetSeverities = new Map();
|
||||||
|
for (const item of alertStatus.warnings || []) {
|
||||||
|
if (!targetSeverities.has(item.target_id)) targetSeverities.set(item.target_id, "warning");
|
||||||
|
}
|
||||||
|
for (const item of alertStatus.alerts || []) {
|
||||||
|
targetSeverities.set(item.target_id, "alert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedTargetCount = targetSeverities.size;
|
||||||
|
const okCount = Math.max(0, targets.length - affectedTargetCount);
|
||||||
|
const filteredTargets = targets.filter((t) => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
<div>
|
(t.name || "").toLowerCase().includes(q) ||
|
||||||
|
(t.host || "").toLowerCase().includes(q) ||
|
||||||
|
(t.dbname || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const groupedRows = [];
|
||||||
|
const groupedMap = new Map();
|
||||||
|
for (const t of filteredTargets) {
|
||||||
|
const meta = getTargetGroupMeta(t);
|
||||||
|
if (!meta) {
|
||||||
|
groupedRows.push({ type: "single", target: t });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groupedMap.has(meta.id)) {
|
||||||
|
const groupRow = { type: "group", groupId: meta.id, groupName: meta.name, targets: [] };
|
||||||
|
groupedMap.set(meta.id, groupRow);
|
||||||
|
groupedRows.push(groupRow);
|
||||||
|
}
|
||||||
|
groupedMap.get(meta.id).targets.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-page">
|
||||||
<h2>Dashboard Overview</h2>
|
<h2>Dashboard Overview</h2>
|
||||||
<div className="grid three">
|
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
|
||||||
<div className="card stat">
|
<div className="dashboard-kpis-grid">
|
||||||
|
<div className="card stat kpi-card">
|
||||||
|
<div className="kpi-orb blue" />
|
||||||
<strong>{targets.length}</strong>
|
<strong>{targets.length}</strong>
|
||||||
<span>Targets</span>
|
<span className="kpi-label">Total Targets</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat">
|
<div className="card stat kpi-card ok">
|
||||||
<strong>{targets.length}</strong>
|
<div className="kpi-orb green" />
|
||||||
<span>Status OK (placeholder)</span>
|
<strong>{okCount}</strong>
|
||||||
|
<span className="kpi-label">Status OK</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card stat">
|
<div className="card stat kpi-card warning">
|
||||||
<strong>0</strong>
|
<div className="kpi-orb amber" />
|
||||||
<span>Alerts (placeholder)</span>
|
<strong>{alertStatus.warning_count || 0}</strong>
|
||||||
|
<span className="kpi-label">Warnings</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat kpi-card alert">
|
||||||
|
<div className="kpi-orb red" />
|
||||||
|
<strong>{alertStatus.alert_count || 0}</strong>
|
||||||
|
<span className="kpi-label">Alerts</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
|
||||||
|
<div className="card dashboard-targets-card">
|
||||||
|
<div className="dashboard-targets-head">
|
||||||
|
<div>
|
||||||
<h3>Targets</h3>
|
<h3>Targets</h3>
|
||||||
<table>
|
<span>{filteredTargets.length} shown of {targets.length} registered</span>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
<div className="dashboard-target-search">
|
||||||
<th>Name</th>
|
<input
|
||||||
<th>Host</th>
|
value={search}
|
||||||
<th>DB</th>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<th>Aktion</th>
|
placeholder="Search by name, host, or database..."
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
</div>
|
||||||
{targets.map((t) => (
|
|
||||||
<tr key={t.id}>
|
<div className="dashboard-target-list">
|
||||||
<td>{t.name}</td>
|
{groupedRows.map((row) => {
|
||||||
<td>{t.host}:{t.port}</td>
|
if (row.type === "single") {
|
||||||
<td>{t.dbname}</td>
|
const t = row.target;
|
||||||
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
|
const severity = targetSeverities.get(t.id) || "ok";
|
||||||
</tr>
|
return (
|
||||||
))}
|
<article className="dashboard-target-card" key={`single-${t.id}`}>
|
||||||
</tbody>
|
<div className="target-main">
|
||||||
</table>
|
<div className="target-title-row">
|
||||||
|
<h4>{t.name}</h4>
|
||||||
|
<span className={`status-chip ${severity}`}>
|
||||||
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Host:</strong> {t.host}:{t.port}</p>
|
||||||
|
<p><strong>DB:</strong> {t.dbname}</p>
|
||||||
|
</div>
|
||||||
|
<div className="target-actions">
|
||||||
|
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highestSeverity = row.targets.some((t) => targetSeverities.get(t.id) === "alert")
|
||||||
|
? "alert"
|
||||||
|
: row.targets.some((t) => targetSeverities.get(t.id) === "warning")
|
||||||
|
? "warning"
|
||||||
|
: "ok";
|
||||||
|
const first = row.targets[0];
|
||||||
|
const isOpen = !!openGroups[row.groupId];
|
||||||
|
return (
|
||||||
|
<article className="dashboard-target-card dashboard-target-group" key={`group-${row.groupId}`}>
|
||||||
|
<div className="target-main">
|
||||||
|
<div className="target-title-row">
|
||||||
|
<h4>{row.groupName}</h4>
|
||||||
|
<span className={`status-chip ${highestSeverity}`}>
|
||||||
|
{highestSeverity === "alert" ? "Alert" : highestSeverity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p><strong>Host:</strong> {first.host}:{first.port}</p>
|
||||||
|
<p><strong>DB:</strong> All databases ({row.targets.length})</p>
|
||||||
|
</div>
|
||||||
|
<div className="target-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="table-action-btn edit"
|
||||||
|
onClick={() => setOpenGroups((prev) => ({ ...prev, [row.groupId]: !prev[row.groupId] }))}
|
||||||
|
>
|
||||||
|
{isOpen ? "Hide DBs" : "Show DBs"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="dashboard-group-list">
|
||||||
|
{row.targets.map((t) => {
|
||||||
|
const severity = targetSeverities.get(t.id) || "ok";
|
||||||
|
return (
|
||||||
|
<div key={`child-${t.id}`} className="dashboard-group-item">
|
||||||
|
<div>
|
||||||
|
<strong>{t.dbname}</strong>
|
||||||
|
<span className={`status-chip ${severity}`}>
|
||||||
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link className="table-action-btn details small-btn" to={`/targets/${t.id}`}>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredTargets.length === 0 && (
|
||||||
|
<div className="dashboard-empty">No targets match your search.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function LoginPage() {
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Login fehlgeschlagen");
|
setError("Login failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,16 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="card login-card" onSubmit={submit}>
|
<form className="card login-card" onSubmit={submit}>
|
||||||
|
<div className="login-logo-wrap" aria-hidden="true">
|
||||||
|
<img src="/nexapg-logo.svg" alt="NexaPG" className="login-logo" />
|
||||||
|
</div>
|
||||||
<div className="login-eyebrow">NexaPG Monitor</div>
|
<div className="login-eyebrow">NexaPG Monitor</div>
|
||||||
<h2>Willkommen zurück</h2>
|
<h2>Welcome back</h2>
|
||||||
<p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
|
<p className="login-subtitle">Sign in to access monitoring and query insights.</p>
|
||||||
<div className="input-shell">
|
<div className="input-shell">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="E-Mail"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -49,7 +52,7 @@ export function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button className="login-cta" disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
<button className="login-cta" disabled={loading}>{loading ? "Please wait..." : "Sign in"}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,21 +2,87 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function scoreQuery(row) {
|
||||||
|
const mean = Number(row.mean_time || 0);
|
||||||
|
const calls = Number(row.calls || 0);
|
||||||
|
const total = Number(row.total_time || 0);
|
||||||
|
const rows = Number(row.rows || 0);
|
||||||
|
return mean * 1.4 + total * 0.9 + calls * 0.2 + Math.min(rows / 50, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyQuery(row) {
|
||||||
|
if ((row.mean_time || 0) > 100) return { label: "Very Slow", kind: "danger" };
|
||||||
|
if ((row.total_time || 0) > 250) return { label: "Heavy", kind: "warn" };
|
||||||
|
if ((row.calls || 0) > 500) return { label: "Frequent", kind: "info" };
|
||||||
|
return { label: "Normal", kind: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactSql(sql) {
|
||||||
|
if (!sql) return "-";
|
||||||
|
return sql.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryTips(row) {
|
||||||
|
if (!row) return [];
|
||||||
|
const tips = [];
|
||||||
|
const sql = (row.query_text || "").toLowerCase();
|
||||||
|
|
||||||
|
if ((row.mean_time || 0) > 100) {
|
||||||
|
tips.push("High latency per call: inspect execution plan with EXPLAIN (ANALYZE, BUFFERS).");
|
||||||
|
}
|
||||||
|
if ((row.total_time || 0) > 500) {
|
||||||
|
tips.push("High cumulative runtime: optimize this query first for fastest overall impact.");
|
||||||
|
}
|
||||||
|
if ((row.calls || 0) > 500) {
|
||||||
|
tips.push("Very frequent query: consider caching or reducing call frequency in application code.");
|
||||||
|
}
|
||||||
|
if ((row.rows || 0) > 100000) {
|
||||||
|
tips.push("Large row output: return fewer columns/rows or add pagination at query/API layer.");
|
||||||
|
}
|
||||||
|
if (/select\s+\*/.test(sql)) {
|
||||||
|
tips.push("Avoid SELECT *: request only required columns to reduce IO and transfer cost.");
|
||||||
|
}
|
||||||
|
if (/order\s+by/.test(sql) && !/limit\s+\d+/.test(sql)) {
|
||||||
|
tips.push("ORDER BY without LIMIT can be expensive: add LIMIT where possible.");
|
||||||
|
}
|
||||||
|
if (/where/.test(sql) && / from /.test(sql)) {
|
||||||
|
tips.push("Filter query detected: verify indexes exist on WHERE / JOIN columns.");
|
||||||
|
}
|
||||||
|
if (/like\s+'%/.test(sql)) {
|
||||||
|
tips.push("Leading wildcard LIKE can bypass indexes: consider trigram index (pg_trgm).");
|
||||||
|
}
|
||||||
|
if (tips.length === 0) {
|
||||||
|
tips.push("No obvious anti-pattern detected. Validate with EXPLAIN and index usage statistics.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tips.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
export function QueryInsightsPage() {
|
export function QueryInsightsPage() {
|
||||||
const { tokens, refresh } = useAuth();
|
const { tokens, refresh } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
|
const [selectedQuery, setSelectedQuery] = useState(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const t = await apiFetch("/targets", {}, tokens, refresh);
|
const t = await apiFetch("/targets", {}, tokens, refresh);
|
||||||
setTargets(t);
|
const supported = t.filter((item) => item.use_pg_stat_statements !== false);
|
||||||
if (t.length > 0) setTargetId(String(t[0].id));
|
setTargets(supported);
|
||||||
|
if (supported.length > 0) setTargetId(String(supported[0].id));
|
||||||
|
else setTargetId("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -27,20 +93,59 @@ export function QueryInsightsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
|
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
|
||||||
setRows(data);
|
setRows(data);
|
||||||
|
setSelectedQuery(data[0] || null);
|
||||||
|
setPage(1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [targetId, tokens, refresh]);
|
}, [targetId, tokens, refresh]);
|
||||||
|
|
||||||
|
const dedupedByQueryId = [...rows].reduce((acc, row) => {
|
||||||
|
if (!row?.queryid) return acc;
|
||||||
|
if (!acc[row.queryid]) acc[row.queryid] = row;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const uniqueRows = Object.values(dedupedByQueryId);
|
||||||
|
|
||||||
|
const sorted = [...uniqueRows].sort((a, b) => (b.total_time || 0) - (a.total_time || 0));
|
||||||
|
const byMean = [...uniqueRows].sort((a, b) => (b.mean_time || 0) - (a.mean_time || 0));
|
||||||
|
const byCalls = [...uniqueRows].sort((a, b) => (b.calls || 0) - (a.calls || 0));
|
||||||
|
const byRows = [...uniqueRows].sort((a, b) => (b.rows || 0) - (a.rows || 0));
|
||||||
|
const byPriority = [...uniqueRows].sort((a, b) => scoreQuery(b) - scoreQuery(a));
|
||||||
|
|
||||||
|
const filtered = byPriority.filter((r) => {
|
||||||
|
if (!search.trim()) return true;
|
||||||
|
return compactSql(r.query_text).toLowerCase().includes(search.toLowerCase());
|
||||||
|
});
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
const selectedTips = buildQueryTips(selectedQuery);
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: "priority", title: "Optimization Priority", row: byPriority[0], subtitle: "Best first candidate to optimize" },
|
||||||
|
{ key: "total", title: "Longest Total Time", row: sorted[0], subtitle: "Biggest cumulative runtime impact" },
|
||||||
|
{ key: "mean", title: "Highest Mean Time", row: byMean[0], subtitle: "Slowest single-call latency" },
|
||||||
|
{ key: "calls", title: "Most Frequent", row: byCalls[0], subtitle: "Executed very often" },
|
||||||
|
{ key: "rows", title: "Most Rows Returned", row: byRows[0], subtitle: "Potentially heavy scans" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="query-insights-page">
|
||||||
<h2>Query Insights</h2>
|
<h2>Query Insights</h2>
|
||||||
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
|
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
|
||||||
{error && <div className="card error">{error}</div>}
|
{targets.length === 0 && !loading && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<label>Target </label>
|
No targets with enabled <code>pg_stat_statements</code> are available.
|
||||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
Enable it in <strong>Targets Management</strong> for a target to use Query Insights.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
<div className="card query-toolbar">
|
||||||
|
<div className="field">
|
||||||
|
<label>Target</label>
|
||||||
|
<select value={targetId} onChange={(e) => setTargetId(e.target.value)} disabled={!targets.length}>
|
||||||
{targets.map((t) => (
|
{targets.map((t) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={t.id} value={t.id}>
|
||||||
{t.name}
|
{t.name}
|
||||||
@@ -48,32 +153,141 @@ export function QueryInsightsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="field">
|
||||||
|
<label>Search Query Text</label>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. select * from users"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="card">Loading query insights...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid three">
|
||||||
|
{categories.map((item) => {
|
||||||
|
const r = item.row;
|
||||||
|
const state = r ? classifyQuery(r) : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card query-category"
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => r && setSelectedQuery(r)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<h4>{item.title}</h4>
|
||||||
|
<small>{item.subtitle}</small>
|
||||||
|
{r ? (
|
||||||
|
<>
|
||||||
|
<div className={`query-state ${state.kind}`}>{state.label}</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Total</span>
|
||||||
|
<strong>{Number(r.total_time || 0).toFixed(2)} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Mean</span>
|
||||||
|
<strong>{Number(r.mean_time || 0).toFixed(2)} ms</strong>
|
||||||
|
</div>
|
||||||
|
<div className="query-kpi">
|
||||||
|
<span>Calls</span>
|
||||||
|
<strong>{r.calls}</strong>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="card query-list">
|
||||||
|
<h3>Ranked Queries</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Priority</th>
|
||||||
<th>Calls</th>
|
<th>Calls</th>
|
||||||
<th>Total ms</th>
|
<th>Total ms</th>
|
||||||
<th>Mean ms</th>
|
<th>Mean ms</th>
|
||||||
<th>Rows</th>
|
<th>Rows</th>
|
||||||
<th>Query</th>
|
<th>Query Preview</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{paged.map((r, i) => {
|
||||||
<tr key={i}>
|
const state = classifyQuery(r);
|
||||||
<td>{new Date(r.ts).toLocaleString()}</td>
|
return (
|
||||||
|
<tr key={`${r.queryid}-${i}-${safePage}`} className={selectedQuery?.queryid === r.queryid ? "active-row" : ""}>
|
||||||
|
<td><span className={`query-state ${state.kind}`}>{state.label}</span></td>
|
||||||
<td>{r.calls}</td>
|
<td>{r.calls}</td>
|
||||||
<td>{r.total_time.toFixed(2)}</td>
|
<td>{Number(r.total_time || 0).toFixed(2)}</td>
|
||||||
<td>{r.mean_time.toFixed(2)}</td>
|
<td>{Number(r.mean_time || 0).toFixed(2)}</td>
|
||||||
<td>{r.rows}</td>
|
<td>{r.rows}</td>
|
||||||
<td className="query">{r.query_text || "-"}</td>
|
<td className="query">
|
||||||
|
<button className="table-link" onClick={() => setSelectedQuery(r)}>
|
||||||
|
{compactSql(r.query_text)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div className="pagination">
|
||||||
|
<span className="pagination-info">
|
||||||
|
Showing {paged.length} of {filtered.length} queries
|
||||||
|
</span>
|
||||||
|
<div className="pagination-actions">
|
||||||
|
<button type="button" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span>Page {safePage} / {totalPages}</span>
|
||||||
|
<button type="button" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card query-detail">
|
||||||
|
<h3>Selected Query</h3>
|
||||||
|
{selectedQuery ? (
|
||||||
|
<>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Calls</span><strong>{selectedQuery.calls}</strong></div>
|
||||||
|
<div><span>Total Time</span><strong>{Number(selectedQuery.total_time || 0).toFixed(2)} ms</strong></div>
|
||||||
|
<div><span>Mean Time</span><strong>{Number(selectedQuery.mean_time || 0).toFixed(2)} ms</strong></div>
|
||||||
|
<div><span>Rows</span><strong>{selectedQuery.rows}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="sql-block">
|
||||||
|
<code>{selectedQuery.query_text || "-- no query text available --"}</code>
|
||||||
|
</div>
|
||||||
|
<div className="query-tips">
|
||||||
|
<h4>Optimization Suggestions</h4>
|
||||||
|
<ul>
|
||||||
|
{selectedTips.map((tip, idx) => <li key={idx}>{tip}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="query-hint">
|
||||||
|
Tip: focus first on queries with high <strong>Total Time</strong> (overall impact) and high <strong>Mean Time</strong> (latency hotspots).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No query selected.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
frontend/src/pages/ServiceInfoPage.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const total = Math.max(0, Number(seconds || 0));
|
||||||
|
const d = Math.floor(total / 86400);
|
||||||
|
const h = Math.floor((total % 86400) / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceInfoPage() {
|
||||||
|
const { tokens, refresh, serviceInfo } = useAuth();
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setError("");
|
||||||
|
const data = await apiFetch("/service/info", {}, tokens, refresh);
|
||||||
|
setInfo(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch((e) => setError(String(e.message || e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serviceInfo) setInfo(serviceInfo);
|
||||||
|
}, [serviceInfo]);
|
||||||
|
|
||||||
|
const checkNow = async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
const result = await apiFetch("/service/info/check", { method: "POST" }, tokens, refresh);
|
||||||
|
await load();
|
||||||
|
if (result.last_check_error) {
|
||||||
|
setMessage(`Version check finished with warning: ${result.last_check_error}`);
|
||||||
|
} else if (result.update_available) {
|
||||||
|
setMessage(`Update available: ${result.latest_version}`);
|
||||||
|
} else {
|
||||||
|
setMessage("Version check completed. No update detected.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return <div className="card">Loading service information...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="service-page">
|
||||||
|
<h2>Service Information</h2>
|
||||||
|
<p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok service-msg">{message}</div>}
|
||||||
|
|
||||||
|
<div className={`card service-hero ${info.update_available ? "update" : "ok"}`}>
|
||||||
|
<div>
|
||||||
|
<strong className="service-hero-title">
|
||||||
|
{info.update_available ? `Update available: ${info.latest_version}` : "Service is up to date"}
|
||||||
|
</strong>
|
||||||
|
<p className="muted service-hero-sub">
|
||||||
|
Automatic release checks run every 30 seconds. Source: official NexaPG upstream releases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
|
||||||
|
Check Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid three">
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Application</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>App Name</span>
|
||||||
|
<strong>{info.app_name}</strong>
|
||||||
|
<span>Environment</span>
|
||||||
|
<strong>{info.environment}</strong>
|
||||||
|
<span>API Prefix</span>
|
||||||
|
<strong>{info.api_prefix}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Runtime</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Host</span>
|
||||||
|
<strong>{info.hostname}</strong>
|
||||||
|
<span>Python</span>
|
||||||
|
<strong>{info.python_version}</strong>
|
||||||
|
<span>Uptime</span>
|
||||||
|
<strong>{formatUptime(info.uptime_seconds)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Version Status</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Current NexaPG Version</span>
|
||||||
|
<strong>{info.app_version}</strong>
|
||||||
|
<span>Latest Known Version</span>
|
||||||
|
<strong>{info.latest_version || "-"}</strong>
|
||||||
|
<span>Update Status</span>
|
||||||
|
<strong className={info.update_available ? "service-status-update" : "service-status-ok"}>
|
||||||
|
{info.update_available ? "Update available" : "Up to date"}
|
||||||
|
</strong>
|
||||||
|
<span>Last Check</span>
|
||||||
|
<strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Release Source</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed
|
||||||
|
via UI.
|
||||||
|
</p>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Source Repository</span>
|
||||||
|
<strong>{info.update_source}</strong>
|
||||||
|
<span>Latest Reference Type</span>
|
||||||
|
<strong>{info.latest_ref || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card service-card">
|
||||||
|
<h3>Version Control Policy</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG
|
||||||
|
repository can change that behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
@@ -54,6 +54,15 @@ function MetricsTooltip({ active, payload, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function didMetricSeriesChange(prev = [], next = []) {
|
||||||
|
if (!Array.isArray(prev) || !Array.isArray(next)) return true;
|
||||||
|
if (prev.length !== next.length) return true;
|
||||||
|
if (prev.length === 0 && next.length === 0) return false;
|
||||||
|
const prevLast = prev[prev.length - 1];
|
||||||
|
const nextLast = next[next.length - 1];
|
||||||
|
return prevLast?.ts !== nextLast?.ts || Number(prevLast?.value) !== Number(nextLast?.value);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
async function loadMetric(targetId, metric, range, tokens, refresh) {
|
||||||
const { from, to } = toQueryRange(range);
|
const { from, to } = toQueryRange(range);
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
@@ -66,29 +75,42 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
|
|||||||
|
|
||||||
export function TargetDetailPage() {
|
export function TargetDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { tokens, refresh } = useAuth();
|
const navigate = useNavigate();
|
||||||
|
const { tokens, refresh, uiMode } = useAuth();
|
||||||
const [range, setRange] = useState("1h");
|
const [range, setRange] = useState("1h");
|
||||||
|
const [liveMode, setLiveMode] = useState(false);
|
||||||
const [series, setSeries] = useState({});
|
const [series, setSeries] = useState({});
|
||||||
const [locks, setLocks] = useState([]);
|
const [locks, setLocks] = useState([]);
|
||||||
const [activity, setActivity] = useState([]);
|
const [activity, setActivity] = useState([]);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
const [targetMeta, setTargetMeta] = useState(null);
|
const [targetMeta, setTargetMeta] = useState(null);
|
||||||
|
const [owners, setOwners] = useState([]);
|
||||||
|
const [groupTargets, setGroupTargets] = useState([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
const loadAll = async () => {
|
||||||
|
if (!series.connections?.length) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
|
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo, ownerRows, allTargets] = await Promise.all([
|
||||||
loadMetric(id, "connections_total", range, tokens, refresh),
|
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
|
||||||
loadMetric(id, "xacts_total", range, tokens, refresh),
|
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
|
||||||
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
|
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/locks`, {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/activity`, {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
|
||||||
apiFetch(`/targets/${id}`, {}, tokens, refresh),
|
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
|
||||||
|
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
|
||||||
|
apiFetch("/targets", {}, tokens, refreshRef.current),
|
||||||
]);
|
]);
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setSeries({ connections, xacts, cache });
|
setSeries({ connections, xacts, cache });
|
||||||
@@ -96,17 +118,59 @@ export function TargetDetailPage() {
|
|||||||
setActivity(activityTable);
|
setActivity(activityTable);
|
||||||
setOverview(overviewData);
|
setOverview(overviewData);
|
||||||
setTargetMeta(targetInfo);
|
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([]);
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError(String(e.message || e));
|
if (active) setError(String(e.message || e));
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false);
|
if (active) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
loadAll();
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [id, range, tokens, refresh]);
|
}, [id, range, tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!liveMode) return;
|
||||||
|
let active = true;
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [connections, xacts, cache] = await Promise.all([
|
||||||
|
loadMetric(id, "connections_total", "15m", tokens, refreshRef.current),
|
||||||
|
loadMetric(id, "xacts_total", "15m", tokens, refreshRef.current),
|
||||||
|
loadMetric(id, "cache_hit_ratio", "15m", tokens, refreshRef.current),
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
|
const nextSeries = { connections, xacts, cache };
|
||||||
|
setSeries((prev) => {
|
||||||
|
const changed =
|
||||||
|
didMetricSeriesChange(prev.connections, nextSeries.connections) ||
|
||||||
|
didMetricSeriesChange(prev.xacts, nextSeries.xacts) ||
|
||||||
|
didMetricSeriesChange(prev.cache, nextSeries.cache);
|
||||||
|
return changed ? nextSeries : prev;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep previous chart values if a live tick fails.
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [liveMode, id, tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() => {
|
() => {
|
||||||
@@ -135,7 +199,39 @@ export function TargetDetailPage() {
|
|||||||
[series]
|
[series]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Target Detail...</div>;
|
const easySummary = useMemo(() => {
|
||||||
|
if (!overview) return null;
|
||||||
|
const latest = chartData[chartData.length - 1];
|
||||||
|
const issues = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
if (overview.partial_failures?.length > 0) warnings.push("Some advanced metrics are currently unavailable.");
|
||||||
|
if ((overview.performance?.deadlocks || 0) > 0) issues.push("Deadlocks were detected.");
|
||||||
|
if ((overview.replication?.mode === "standby") && (overview.replication?.replay_lag_seconds || 0) > 5) {
|
||||||
|
issues.push("Replication lag is above 5 seconds.");
|
||||||
|
}
|
||||||
|
if ((latest?.cache || 0) > 0 && latest.cache < 90) warnings.push("Cache hit ratio is below 90%.");
|
||||||
|
if ((latest?.connections || 0) > 120) warnings.push("Connection count is relatively high.");
|
||||||
|
if ((locks?.length || 0) > 150) warnings.push("High number of active locks.");
|
||||||
|
|
||||||
|
const health = issues.length > 0 ? "problem" : warnings.length > 0 ? "warning" : "ok";
|
||||||
|
const message =
|
||||||
|
health === "ok"
|
||||||
|
? "Everything looks healthy. No major risks detected right now."
|
||||||
|
: health === "warning"
|
||||||
|
? "System is operational, but there are signals you should watch."
|
||||||
|
: "Attention required. Critical signals need investigation.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
message,
|
||||||
|
issues,
|
||||||
|
warnings,
|
||||||
|
latest,
|
||||||
|
};
|
||||||
|
}, [overview, chartData, locks]);
|
||||||
|
|
||||||
|
if (loading) return <div className="card">Loading target detail...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
const role = overview?.instance?.role || "-";
|
const role = overview?.instance?.role || "-";
|
||||||
@@ -148,7 +244,106 @@ export function TargetDetailPage() {
|
|||||||
Target Detail {targetMeta?.name || `#${id}`}
|
Target Detail {targetMeta?.name || `#${id}`}
|
||||||
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||||
</h2>
|
</h2>
|
||||||
{overview && (
|
{groupTargets.length > 1 && (
|
||||||
|
<div className="field target-db-switcher">
|
||||||
|
<label>Database in this target group</label>
|
||||||
|
<select
|
||||||
|
value={String(id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const targetId = e.target.value;
|
||||||
|
if (targetId && String(targetId) !== String(id)) {
|
||||||
|
navigate(`/targets/${targetId}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupTargets.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.dbname} ({item.name})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="owner-row">
|
||||||
|
<span className="muted">Responsible users:</span>
|
||||||
|
{owners.length > 0 ? owners.map((item) => <span key={item.user_id} className="owner-pill">{item.email}</span>) : <span className="muted">none assigned</span>}
|
||||||
|
</div>
|
||||||
|
{uiMode === "easy" && overview && easySummary && (
|
||||||
|
<>
|
||||||
|
<div className={`card easy-status ${easySummary.health}`}>
|
||||||
|
<h3>System Health</h3>
|
||||||
|
<p>{easySummary.message}</p>
|
||||||
|
<div className="easy-badge-row">
|
||||||
|
<span className={`easy-badge ${easySummary.health}`}>
|
||||||
|
{easySummary.health === "ok" ? "OK" : easySummary.health === "warning" ? "Warning" : "Problem"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid three">
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.connections, 0)}</strong>
|
||||||
|
<span>Current Connections</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.tps, 2)}</strong>
|
||||||
|
<span>Transactions/sec (approx)</span>
|
||||||
|
</div>
|
||||||
|
<div className="card stat">
|
||||||
|
<strong>{formatNumber(easySummary.latest?.cache, 2)}%</strong>
|
||||||
|
<span>Cache Hit Ratio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Quick Explanation</h3>
|
||||||
|
{easySummary.issues.length === 0 && easySummary.warnings.length === 0 && (
|
||||||
|
<p>No immediate problems were detected. Keep monitoring over time.</p>
|
||||||
|
)}
|
||||||
|
{easySummary.issues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<strong>Problems</strong>
|
||||||
|
<ul className="easy-list">
|
||||||
|
{easySummary.issues.map((item, idx) => <li key={`i-${idx}`}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{easySummary.warnings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<strong>Things to watch</strong>
|
||||||
|
<ul className="easy-list">
|
||||||
|
{easySummary.warnings.map((item, idx) => <li key={`w-${idx}`}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid two">
|
||||||
|
<div className="card">
|
||||||
|
<h3>Instance Summary</h3>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Role</span><strong>{overview.instance.role}</strong></div>
|
||||||
|
<div><span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong></div>
|
||||||
|
<div><span>Target Port</span><strong>{targetMeta?.port ?? "-"}</strong></div>
|
||||||
|
<div><span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong></div>
|
||||||
|
<div><span>Replication Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
|
||||||
|
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Activity Snapshot</h3>
|
||||||
|
<div className="overview-metrics">
|
||||||
|
<div><span>Running Sessions</span><strong>{activity.filter((a) => a.state === "active").length}</strong></div>
|
||||||
|
<div><span>Total Sessions</span><strong>{activity.length}</strong></div>
|
||||||
|
<div><span>Current Locks</span><strong>{locks.length}</strong></div>
|
||||||
|
<div><span>Deadlocks</span><strong>{overview.performance.deadlocks ?? 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uiMode === "dba" && overview && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Database Overview</h3>
|
<h3>Database Overview</h3>
|
||||||
<div className="grid three overview-kv">
|
<div className="grid three overview-kv">
|
||||||
@@ -157,7 +352,7 @@ export function TargetDetailPage() {
|
|||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeit seit Start des Postgres-Prozesses">
|
<div title="Time since PostgreSQL postmaster start">
|
||||||
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
||||||
@@ -165,16 +360,16 @@ export function TargetDetailPage() {
|
|||||||
<span>Target Port</span>
|
<span>Target Port</span>
|
||||||
<strong>{targetMeta?.port ?? "-"}</strong>
|
<strong>{targetMeta?.port ?? "-"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Groesse der aktuell verbundenen Datenbank">
|
<div title="Current database total size">
|
||||||
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Gesamtgroesse der WAL-Dateien (falls verfuegbar)">
|
<div title="Total WAL directory size (when available)">
|
||||||
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Optional ueber Agent/SSH ermittelbar">
|
<div title="Optional metric via future Agent/SSH provider">
|
||||||
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeitliche Replikationsverzoegerung auf Standby">
|
<div title="Replication replay delay on standby">
|
||||||
<span>Replay Lag</span>
|
<span>Replay Lag</span>
|
||||||
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
||||||
{formatSeconds(overview.replication.replay_lag_seconds)}
|
{formatSeconds(overview.replication.replay_lag_seconds)}
|
||||||
@@ -255,23 +450,43 @@ export function TargetDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="range-picker">
|
<div className="range-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`live-btn ${liveMode ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setLiveMode((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (next) setRange("15m");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</button>
|
||||||
{Object.keys(ranges).map((r) => (
|
{Object.keys(ranges).map((r) => (
|
||||||
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => {
|
||||||
|
setLiveMode(false);
|
||||||
|
setRange(r);
|
||||||
|
}}
|
||||||
|
className={r === range ? "active" : ""}
|
||||||
|
>
|
||||||
{r}
|
{r}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ height: 320 }}>
|
<div className="card" style={{ height: 320 }}>
|
||||||
<h3>Connections / TPS approx / Cache hit ratio</h3>
|
<h3>Connections / TPS (approx) / Cache Hit Ratio</h3>
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="ts" hide />
|
<XAxis dataKey="ts" hide />
|
||||||
<YAxis yAxisId="left" />
|
<YAxis yAxisId="left" />
|
||||||
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
|
||||||
<Tooltip content={<MetricsTooltip />} />
|
<Tooltip content={<MetricsTooltip />} />
|
||||||
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} />
|
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} />
|
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} />
|
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} isAnimationActive={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { useAuth } from "../state";
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
|
name: "",
|
||||||
|
host: "",
|
||||||
|
port: 5432,
|
||||||
|
dbname: "postgres",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
sslmode: "prefer",
|
||||||
|
use_pg_stat_statements: true,
|
||||||
|
discover_all_databases: false,
|
||||||
|
owner_user_ids: [],
|
||||||
|
tags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyEditForm = {
|
||||||
|
id: null,
|
||||||
name: "",
|
name: "",
|
||||||
host: "",
|
host: "",
|
||||||
port: 5432,
|
port: 5432,
|
||||||
@@ -11,22 +26,120 @@ const emptyForm = {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
sslmode: "prefer",
|
sslmode: "prefer",
|
||||||
tags: {},
|
use_pg_stat_statements: true,
|
||||||
|
owner_user_ids: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toggleOwner(ids, userId) {
|
||||||
|
return ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
|
||||||
|
const pickerRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const filtered = candidates.filter((item) =>
|
||||||
|
item.email.toLowerCase().includes(query.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event) => {
|
||||||
|
if (!pickerRef.current?.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="owner-picker" ref={pickerRef}>
|
||||||
|
<div className="owner-selected">
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
selected.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`selected-${item.user_id}`}
|
||||||
|
type="button"
|
||||||
|
className="owner-selected-chip"
|
||||||
|
onClick={() => onToggle(item.user_id)}
|
||||||
|
title="Remove owner"
|
||||||
|
>
|
||||||
|
<span>{item.email}</span>
|
||||||
|
<span aria-hidden="true">x</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="muted">No owners selected yet.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`owner-search-shell ${open ? "open" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="owner-search-input"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
placeholder="Search users by email..."
|
||||||
|
/>
|
||||||
|
<button type="button" className="owner-search-toggle" onClick={() => setOpen((prev) => !prev)}>
|
||||||
|
v
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="owner-dropdown">
|
||||||
|
<div className="owner-search-results">
|
||||||
|
{filtered.map((item) => {
|
||||||
|
const active = selectedIds.includes(item.user_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.user_id}
|
||||||
|
type="button"
|
||||||
|
className={`owner-result ${active ? "active" : ""}`}
|
||||||
|
onClick={() => onToggle(item.user_id)}
|
||||||
|
>
|
||||||
|
<span>{item.email}</span>
|
||||||
|
<small>{item.role}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TargetsPage() {
|
export function TargetsPage() {
|
||||||
const { tokens, refresh, me } = useAuth();
|
const { tokens, refresh, me } = useAuth();
|
||||||
const [targets, setTargets] = useState([]);
|
const [targets, setTargets] = useState([]);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
|
||||||
|
const [saveState, setSaveState] = useState({ loading: false, message: "" });
|
||||||
|
const [ownerCandidates, setOwnerCandidates] = useState([]);
|
||||||
|
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
|
||||||
|
const [editOwnerQuery, setEditOwnerQuery] = useState("");
|
||||||
|
|
||||||
const canManage = me?.role === "admin" || me?.role === "operator";
|
const canManage = me?.role === "admin" || me?.role === "operator";
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (canManage) {
|
||||||
|
const [targetRows, candidates] = await Promise.all([
|
||||||
|
apiFetch("/targets", {}, tokens, refresh),
|
||||||
|
apiFetch("/targets/owner-candidates", {}, tokens, refresh),
|
||||||
|
]);
|
||||||
|
setTargets(targetRows);
|
||||||
|
setOwnerCandidates(candidates);
|
||||||
|
} else {
|
||||||
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e.message || e));
|
setError(String(e.message || e));
|
||||||
@@ -37,7 +150,7 @@ export function TargetsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, [canManage]);
|
||||||
|
|
||||||
const createTarget = async (e) => {
|
const createTarget = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -50,8 +163,33 @@ export function TargetsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
setTestState({ loading: true, message: "", ok: null });
|
||||||
|
try {
|
||||||
|
const result = await apiFetch(
|
||||||
|
"/targets/test-connection",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
host: form.host,
|
||||||
|
port: form.port,
|
||||||
|
dbname: form.dbname,
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
sslmode: form.sslmode,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setTestState({ loading: false, message: `${result.message} (PostgreSQL ${result.server_version})`, ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
setTestState({ loading: false, message: String(e.message || e), ok: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTarget = async (id) => {
|
const deleteTarget = async (id) => {
|
||||||
if (!confirm("Target löschen?")) return;
|
if (!confirm("Delete target?")) return;
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
||||||
await load();
|
await load();
|
||||||
@@ -60,41 +198,105 @@ export function TargetsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEdit = (target) => {
|
||||||
|
setEditing(true);
|
||||||
|
setSaveState({ loading: false, message: "" });
|
||||||
|
setEditOwnerQuery("");
|
||||||
|
setEditForm({
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
host: target.host,
|
||||||
|
port: target.port,
|
||||||
|
dbname: target.dbname,
|
||||||
|
username: target.username,
|
||||||
|
password: "",
|
||||||
|
sslmode: target.sslmode,
|
||||||
|
use_pg_stat_statements: target.use_pg_stat_statements !== false,
|
||||||
|
owner_user_ids: Array.isArray(target.owner_user_ids) ? target.owner_user_ids : [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditing(false);
|
||||||
|
setEditForm(emptyEditForm);
|
||||||
|
setSaveState({ loading: false, message: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editForm.id) return;
|
||||||
|
setSaveState({ loading: true, message: "" });
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: editForm.name,
|
||||||
|
host: editForm.host,
|
||||||
|
port: Number(editForm.port),
|
||||||
|
dbname: editForm.dbname,
|
||||||
|
username: editForm.username,
|
||||||
|
sslmode: editForm.sslmode,
|
||||||
|
use_pg_stat_statements: !!editForm.use_pg_stat_statements,
|
||||||
|
owner_user_ids: editForm.owner_user_ids || [],
|
||||||
|
};
|
||||||
|
if (editForm.password.trim()) payload.password = editForm.password;
|
||||||
|
|
||||||
|
await apiFetch(`/targets/${editForm.id}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
|
||||||
|
setSaveState({ loading: false, message: "Target updated." });
|
||||||
|
setEditing(false);
|
||||||
|
setEditForm(emptyEditForm);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setSaveState({ loading: false, message: String(e.message || e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="targets-page">
|
||||||
<h2>Targets Management</h2>
|
<h2>Targets Management</h2>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<form className="card grid two" onSubmit={createTarget}>
|
<details className="card collapsible">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>New Target</h3>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<form className="target-form grid two" onSubmit={createTarget}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
<small>Eindeutiger Anzeigename im Dashboard.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Host</label>
|
<label>Host</label>
|
||||||
<input placeholder="z.B. 172.16.0.106 oder db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
||||||
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Port</label>
|
<label>Port</label>
|
||||||
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
||||||
<small>Standard PostgreSQL Port ist 5432 (oder gemappter Host-Port).</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>DB Name</label>
|
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
|
||||||
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
<input
|
||||||
<small>Name der Datenbank, die überwacht werden soll.</small>
|
placeholder={form.discover_all_databases ? "e.g. postgres" : "e.g. postgres or appdb"}
|
||||||
|
value={form.dbname}
|
||||||
|
onChange={(e) => setForm({ ...form, dbname: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{form.discover_all_databases
|
||||||
|
? "Connection database used to crawl all available databases on this instance."
|
||||||
|
: "Single database to monitor for this target."}
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||||
<small>DB User mit Leserechten auf Stats-Views.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||||
<small>Wird verschlüsselt in der Core-DB gespeichert.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>SSL Mode</label>
|
<label>SSL Mode</label>
|
||||||
@@ -104,32 +306,162 @@ export function TargetsPage() {
|
|||||||
<option value="require">require</option>
|
<option value="require">require</option>
|
||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
|
If you see "rejected SSL upgrade", switch to <code>disable</code>.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field toggle-field">
|
||||||
<label> </label>
|
<label>Query Insights Source</label>
|
||||||
<button>Target anlegen</button>
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.use_pg_stat_statements}
|
||||||
|
onChange={(e) => setForm({ ...form, use_pg_stat_statements: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Use pg_stat_statements for this target</strong>
|
||||||
|
<small>Disable this if the extension is unavailable on the target.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field toggle-field">
|
||||||
|
<label>Scope</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.discover_all_databases}
|
||||||
|
onChange={(e) => setForm({ ...form, discover_all_databases: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Discover and add all databases</strong>
|
||||||
|
<small>Requires credentials with access to list databases (typically a superuser).</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>Responsible Users (Target Owners)</label>
|
||||||
|
<OwnerPicker
|
||||||
|
candidates={ownerCandidates}
|
||||||
|
selectedIds={form.owner_user_ids}
|
||||||
|
query={createOwnerQuery}
|
||||||
|
onQueryChange={setCreateOwnerQuery}
|
||||||
|
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
|
||||||
|
/>
|
||||||
|
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||||
|
</div>
|
||||||
|
<div className="field submit-field field-full">
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
|
||||||
|
{testState.loading ? "Testing..." : "Test connection"}
|
||||||
|
</button>
|
||||||
|
<button className="primary-btn">Create target</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{testState.message && (
|
||||||
|
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
|
||||||
)}
|
)}
|
||||||
{canManage && (
|
</details>
|
||||||
<div className="card tips">
|
)}
|
||||||
<strong>Troubleshooting</strong>
|
|
||||||
<p>
|
{canManage && editing && (
|
||||||
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
|
<section className="card">
|
||||||
</p>
|
<h3>Edit Target</h3>
|
||||||
<p>
|
<form className="target-form grid two" onSubmit={saveEdit}>
|
||||||
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
|
<div className="field">
|
||||||
</p>
|
<label>Name</label>
|
||||||
<p>
|
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
|
||||||
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Host</label>
|
||||||
|
<input value={editForm.host} onChange={(e) => setEditForm({ ...editForm, host: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: Number(e.target.value) })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>DB Name</label>
|
||||||
|
<input value={editForm.dbname} onChange={(e) => setEditForm({ ...editForm, dbname: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<input value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>New Password (optional)</label>
|
||||||
|
<input type="password" placeholder="Leave empty to keep current" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>SSL Mode</label>
|
||||||
|
<select value={editForm.sslmode} onChange={(e) => setEditForm({ ...editForm, sslmode: e.target.value })}>
|
||||||
|
<option value="disable">disable</option>
|
||||||
|
<option value="prefer">prefer</option>
|
||||||
|
<option value="require">require</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field toggle-field">
|
||||||
|
<label>Query Insights Source</label>
|
||||||
|
<label className="toggle-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!editForm.use_pg_stat_statements}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, use_pg_stat_statements: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-ui" aria-hidden="true" />
|
||||||
|
<span className="toggle-copy">
|
||||||
|
<strong>Use pg_stat_statements for this target</strong>
|
||||||
|
<small>Disable this if the extension is unavailable on the target.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field field-full">
|
||||||
|
<label>Responsible Users (Target Owners)</label>
|
||||||
|
<OwnerPicker
|
||||||
|
candidates={ownerCandidates}
|
||||||
|
selectedIds={editForm.owner_user_ids}
|
||||||
|
query={editOwnerQuery}
|
||||||
|
onQueryChange={setEditOwnerQuery}
|
||||||
|
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
|
||||||
|
/>
|
||||||
|
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||||
|
</div>
|
||||||
|
<div className="field submit-field field-full">
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="secondary-btn" onClick={cancelEdit}>Cancel</button>
|
||||||
|
<button className="primary-btn" disabled={saveState.loading}>{saveState.loading ? "Saving..." : "Save changes"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{saveState.message && <div className="test-connection-result">{saveState.message}</div>}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
<div className="card">
|
|
||||||
|
{canManage && (
|
||||||
|
<details className="card collapsible tips">
|
||||||
|
<summary className="collapse-head">
|
||||||
|
<div>
|
||||||
|
<h3>Troubleshooting</h3>
|
||||||
|
<p>Quick checks for the most common connection issues.</p>
|
||||||
|
</div>
|
||||||
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
|
</summary>
|
||||||
|
<p>
|
||||||
|
<code>Connection refused</code>: host/port is wrong or database is unreachable.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>rejected SSL upgrade</code>: set SSL mode to <code>disable</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>localhost</code> points to the backend container itself, not your host machine.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card targets-table">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Lade Targets...</p>
|
<p>Loading targets...</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -137,7 +469,9 @@ export function TargetsPage() {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Host</th>
|
<th>Host</th>
|
||||||
<th>DB</th>
|
<th>DB</th>
|
||||||
<th>Aktionen</th>
|
<th>Owners</th>
|
||||||
|
<th>Query Insights</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -146,9 +480,43 @@ export function TargetsPage() {
|
|||||||
<td>{t.name}</td>
|
<td>{t.name}</td>
|
||||||
<td>{t.host}:{t.port}</td>
|
<td>{t.host}:{t.port}</td>
|
||||||
<td>{t.dbname}</td>
|
<td>{t.dbname}</td>
|
||||||
|
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
|
||||||
<td>
|
<td>
|
||||||
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
|
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
|
||||||
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
|
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Details
|
||||||
|
</Link>
|
||||||
|
{canManage && (
|
||||||
|
<button className="table-action-btn edit" onClick={() => startEdit(t)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M3 17.25V21h3.75L19.81 7.94l-3.75-3.75L3 17.25zm2.92 2.33H5v-.92l10.06-10.06.92.92L5.92 19.58zM20.71 6.04a1 1 0 0 0 0-1.41L19.37 3.3a1 1 0 0 0-1.41 0l-1.13 1.12 3.75 3.75 1.13-1.13z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<button className="table-action-btn delete" onClick={() => deleteTarget(t.id)}>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
||||||
|
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
100
frontend/src/pages/UserSettingsPage.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
export function UserSettingsPage() {
|
||||||
|
const { tokens, refresh } = useAuth();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
current_password: "",
|
||||||
|
new_password: "",
|
||||||
|
confirm_password: "",
|
||||||
|
});
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage("");
|
||||||
|
setError("");
|
||||||
|
if (form.new_password.length < 8) {
|
||||||
|
setError("New password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.new_password !== form.confirm_password) {
|
||||||
|
setError("Password confirmation does not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
await apiFetch(
|
||||||
|
"/me/password",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: form.current_password,
|
||||||
|
new_password: form.new_password,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
refresh
|
||||||
|
);
|
||||||
|
setForm({ current_password: "", new_password: "", confirm_password: "" });
|
||||||
|
setMessage("Password changed successfully.");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-settings-page">
|
||||||
|
<h2>User Settings</h2>
|
||||||
|
<p className="muted">Manage your personal account security settings.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok">{message}</div>}
|
||||||
|
|
||||||
|
<div className="card user-settings-card">
|
||||||
|
<h3>Change Password</h3>
|
||||||
|
<form className="grid two" onSubmit={submit}>
|
||||||
|
<div className="admin-field field-full">
|
||||||
|
<label>Current password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.current_password}
|
||||||
|
onChange={(e) => setForm({ ...form, current_password: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.new_password}
|
||||||
|
onChange={(e) => setForm({ ...form, new_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={(e) => setForm({ ...form, confirm_password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions field-full">
|
||||||
|
<button className="primary-btn" type="submit" disabled={busy}>
|
||||||
|
{busy ? "Saving..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { API_URL } from "./api";
|
import { API_URL } from "./api";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
|
const UI_MODE_KEY = "nexapg_ui_mode";
|
||||||
|
|
||||||
function loadStorage() {
|
function loadStorage() {
|
||||||
try {
|
try {
|
||||||
@@ -11,10 +12,26 @@ function loadStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadUiMode() {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(UI_MODE_KEY);
|
||||||
|
if (value === "easy" || value === "dba") return value;
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors
|
||||||
|
}
|
||||||
|
return "dba";
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const initial = loadStorage();
|
const initial = loadStorage();
|
||||||
const [tokens, setTokens] = useState(initial?.tokens || null);
|
const [tokens, setTokens] = useState(initial?.tokens || null);
|
||||||
const [me, setMe] = useState(initial?.me || null);
|
const [me, setMe] = useState(initial?.me || null);
|
||||||
|
const [uiMode, setUiModeState] = useState(loadUiMode);
|
||||||
|
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
||||||
|
const [alertToasts, setAlertToasts] = useState([]);
|
||||||
|
const [serviceInfo, setServiceInfo] = useState(null);
|
||||||
|
const knownAlertKeysRef = useRef(new Set());
|
||||||
|
const hasAlertSnapshotRef = useRef(false);
|
||||||
|
|
||||||
const persist = (nextTokens, nextMe) => {
|
const persist = (nextTokens, nextMe) => {
|
||||||
if (nextTokens && nextMe) {
|
if (nextTokens && nextMe) {
|
||||||
@@ -78,7 +95,153 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
|
const dismissAlertToast = (toastId) => {
|
||||||
|
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
|
||||||
|
setTimeout(() => {
|
||||||
|
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
|
||||||
|
}, 220);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
|
||||||
|
setAlertToasts([]);
|
||||||
|
knownAlertKeysRef.current = new Set();
|
||||||
|
hasAlertSnapshotRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const pushToastsForNewItems = (items) => {
|
||||||
|
if (!items.length) return;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const nextToasts = items.slice(0, 4).map((item, idx) => ({
|
||||||
|
id: `${createdAt}-${idx}-${item.alert_key}`,
|
||||||
|
alertKey: item.alert_key,
|
||||||
|
severity: item.severity,
|
||||||
|
title: item.name,
|
||||||
|
target: item.target_name,
|
||||||
|
message: item.message,
|
||||||
|
closing: false,
|
||||||
|
}));
|
||||||
|
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
|
||||||
|
for (const toast of nextToasts) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
dismissAlertToast(toast.id);
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAlertStatus = async () => {
|
||||||
|
const request = async (accessToken) =>
|
||||||
|
fetch(`${API_URL}/alerts/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await request(tokens.accessToken);
|
||||||
|
if (res.status === 401 && tokens.refreshToken) {
|
||||||
|
const refreshed = await refresh();
|
||||||
|
if (refreshed?.accessToken) {
|
||||||
|
res = await request(refreshed.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!mounted) return;
|
||||||
|
setAlertStatus(payload);
|
||||||
|
|
||||||
|
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
|
||||||
|
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
|
||||||
|
if (!hasAlertSnapshotRef.current) {
|
||||||
|
knownAlertKeysRef.current = currentKeys;
|
||||||
|
hasAlertSnapshotRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
|
||||||
|
knownAlertKeysRef.current = currentKeys;
|
||||||
|
pushToastsForNewItems(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAlertStatus().catch(() => {});
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
loadAlertStatus().catch(() => {});
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
setServiceInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const request = async (path, method = "GET") => {
|
||||||
|
const doFetch = async (accessToken) =>
|
||||||
|
fetch(`${API_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await doFetch(tokens.accessToken);
|
||||||
|
if (res.status === 401 && tokens.refreshToken) {
|
||||||
|
const refreshed = await refresh();
|
||||||
|
if (refreshed?.accessToken) {
|
||||||
|
res = await doFetch(refreshed.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runServiceCheck = async () => {
|
||||||
|
await request("/service/info/check", "POST");
|
||||||
|
const info = await request("/service/info", "GET");
|
||||||
|
if (mounted && info) setServiceInfo(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
runServiceCheck().catch(() => {});
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
runServiceCheck().catch(() => {});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [tokens?.accessToken, tokens?.refreshToken]);
|
||||||
|
|
||||||
|
const setUiMode = (nextMode) => {
|
||||||
|
const mode = nextMode === "easy" ? "easy" : "dba";
|
||||||
|
setUiModeState(mode);
|
||||||
|
localStorage.setItem(UI_MODE_KEY, mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
tokens,
|
||||||
|
me,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
uiMode,
|
||||||
|
setUiMode,
|
||||||
|
alertStatus,
|
||||||
|
alertToasts,
|
||||||
|
dismissAlertToast,
|
||||||
|
serviceInfo,
|
||||||
|
serviceUpdateAvailable: !!serviceInfo?.update_available,
|
||||||
|
}),
|
||||||
|
[tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
|
||||||
|
);
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,57 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Change this in every non-local environment.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 80.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
VITE_API_URL=http://localhost:8000/api/v1
|
# Base API URL used at frontend build time.
|
||||||
|
# For reverse proxy + SSL, keep this relative to avoid mixed-content issues.
|
||||||
|
# Example direct mode: VITE_API_URL=http://localhost:8000/api/v1
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||