117 Commits

Author SHA1 Message Date
f0076ff1f4 [NX-501 Issue] Replace GitHub Actions postgres service with Docker container.
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m41s
E2E API Smoke / Core API E2E Smoke (push) Failing after 2m8s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 26s
The PostgreSQL service in the GitHub Actions workflow was replaced by a Docker container for better control and flexibility. Additional steps were added to manage the container lifecycle, including starting, logging, and cleaning up. Also, updated the app version from 0.2.4 to 0.2.5.
2026-02-15 20:14:12 +01:00
8d47c0c378 [NX-501 Issue] Add wait for PostgreSQL in e2e API smoke workflow
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m20s
E2E API Smoke / Core API E2E Smoke (push) Failing after 2m8s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
This change introduces a step in the e2e API smoke workflow to wait for PostgreSQL readiness before executing further steps. It retries the connection multiple times to ensure the database is available, reducing potential errors caused by service unavailability.
2026-02-15 20:07:35 +01:00
7f7cf9179f Remove Trivy scans from container CVE scan workflow
Trivy-based scanning steps and their summaries have been removed from the GitHub Actions workflow. This change focuses on streamlining the workflow by reducing redundancy and relying on alternate scanning methods.
2026-02-15 20:04:20 +01:00
3e317abda8 [NX-501 Issue] Add E2E API smoke test workflow and related test suite
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m45s
E2E API Smoke / Core API E2E Smoke (push) Failing after 24s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Proxy Profile Validation / validate (push) Successful in 3s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 26s
This commit introduces a GitHub Actions workflow for running E2E API smoke tests on main branches and pull requests. It includes a test suite covering authentication, CRUD operations, metrics access, and alerts status. The README is updated with instructions for running the tests locally.
2026-02-15 19:44:33 +01:00
597579376f [NX-204 Issue] Add secret management guidelines and enhance security notes
Some checks failed
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 2m43s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Proxy Profile Validation / validate (push) Successful in 3s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 26s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m41s
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Has been cancelled
Introduced a comprehensive guide for secure production secret handling (`docs/security/secret-management.md`). Updated `.env.example` files with clearer comments on best practices, emphasizing not hardcoding secrets and implementing rotation strategies. Enhanced README with a new section linking to the secret management documentation.
2026-02-15 12:29:40 +01:00
f25792b8d8 Adjust Nginx PID file path in Dockerfile
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m41s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Proxy Profile Validation / validate (push) Successful in 3s
Modified the PID file location in the Nginx configuration to use `/tmp/nginx/nginx.pid` instead of the default paths. This ensures compatibility and avoids permission issues during container runtime.
2026-02-15 12:20:04 +01:00
6093c5dea8 [NX-203 Issue] Add production proxy profile with validation and documentation
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m40s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Proxy Profile Validation / validate (push) Successful in 3s
Introduced a secure, repeatable production proxy profile for reverse proxy and HTTPS deployment, including NGINX configuration, environment variables, and CORS guidance. Added CI workflow for static validation of proxy guardrails and detailed documentation to ensure best practices in deployment.
2026-02-15 12:10:41 +01:00
84bc7b0384 Update NEXAPG version to 0.2.4
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 4m21s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 25s
Bumped the version of NEXAPG from 0.2.2 to 0.2.4 in the configuration file. This ensures the application is aligned with the latest changes or fixes in the updated version.
2026-02-15 11:29:11 +01:00
3932aa56f7 [NX-202 Issue] Add pip-audit CI enforcement for Python dependency security
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m41s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Python Dependency Security / pip-audit (block high/critical) (push) Successful in 50s
This commit integrates pip-audit to enforce vulnerability checks in CI. Dependencies with unresolved HIGH/CRITICAL vulnerabilities will block builds unless explicitly allowlisted. The process is documented, with a strict policy to ensure exceptions are trackable and time-limited.
2026-02-15 10:44:33 +01:00
9657bd7a36 Merge branch 'main' of https://git.nesterovic.cc/nessi/NexaPG into development
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 20s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
2026-02-15 10:33:56 +01:00
574e2eb9a5 Ensure valid Docker Hub namespace in release workflow
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m44s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Added validation to normalize input, reject invalid namespaces, and check for proper formatting in the Docker Hub namespace. This prevents configuration mistakes and ensures compliance with naming requirements.
2026-02-15 10:32:44 +01:00
21a8023bf1 Merge pull request 'Fix CI stability: resolve Docker Scout write/auth issues and harden PG matrix checkout' (#35) from development into main
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 6m20s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 10s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m18s
Reviewed-on: #35
2026-02-14 22:12:28 +00:00
328f69ea5e Update GitHub Actions workflows for improved functionality
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m44s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Migration Safety / Alembic upgrade/downgrade safety (pull_request) Successful in 21s
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 7s
Removed the read-only flag from Docker volume mounts in the container CVE scan workflow to allow modifications. Added `max-parallel` and `fetch-depth` configurations to the PostgreSQL compatibility matrix workflow for better performance and efficiency.
2026-02-14 22:04:58 +01:00
c0077e3dd8 Add -u root flag to container CVE scan workflow
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m41s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Failing after 11m28s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Failing after 11m55s
This ensures the container runs with root user privileges, providing better compatibility and avoiding potential permission issues. The change affects the development workflow configuration for container CVE scanning.
2026-02-14 19:47:34 +01:00
af6ea11079 Refactor Docker Scout integration in CVE scan workflow
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m14s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Simplified the Docker Scout configuration logic by removing unnecessary checks and utilizing Docker's standard auth configuration. Updated environment variable usage and volume mounts to streamline the setup process for scanning containers.
2026-02-14 19:32:50 +01:00
5a7f32541f Add Docker Scout login fallback and temporary caching.
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 1m57s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
This update introduces a fallback mechanism for Docker Scout login when DockerHub credentials are unavailable, ensuring the workflow does not fail. It also replaces direct Docker config usage with temporary caching to improve flexibility and reduce dependency on runner environment setups.
2026-02-14 19:03:30 +01:00
dd3f18bb06 Make Docker Scout scans non-blocking and update config paths.
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m10s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Set `continue-on-error: true` for Docker Scout steps to ensure workflows proceed even if scans fail. Updated volume paths and environment variables for Docker config and credentials to improve scanning compatibility.
2026-02-14 18:55:52 +01:00
f4b18b6cf1 Update Docker Hub Scout config to use local login credentials
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Failing after 1m56s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Replaced the use of Docker Hub secrets with a mounted local docker configuration file for authentication. Added a check to ensure the login config exists before running scans, preventing unnecessary failures. This change enhances flexibility and aligns with local environment setups.
2026-02-14 18:50:46 +01:00
a220e5de99 Add Docker Hub authentication for Scout scans
Some checks failed
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 22s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Failing after 1m53s
This update ensures Docker Scout scans use Docker Hub authentication. If the required credentials are absent, the scans are skipped with a corresponding message. This improves security and prevents unnecessary scan failures.
2026-02-14 18:31:10 +01:00
a5ffafaf9e Update CVE scanning workflow to use JSON format and new tools
All checks were successful
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Successful in 2m9s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Replaced Trivy output format from table to JSON for better processing. Added a summary step to parse and count severities using a Python script. Integrated Docker Scout scans for both backend and frontend, and updated uploaded artifacts to include the new JSON and Scout scan outputs.
2026-02-14 18:24:08 +01:00
d17752b611 Add CVE scan workflow for development branch
Some checks failed
Container CVE Scan (development) / Scan backend/frontend images for CVEs (push) Failing after 2m20s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
This commit introduces a GitHub Actions workflow to scan for CVEs in backend and frontend container images. It uses Trivy for scanning and uploads the reports as artifacts, providing better visibility into vulnerabilities in development builds.
2026-02-14 18:16:54 +01:00
fe05c40426 Merge branch 'main' of https://git.nesterovic.cc/nessi/NexaPG into development
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 10s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
2026-02-14 17:47:34 +01:00
5a0478f47d harden(frontend): switch to nginx:alpine-slim with non-root runtime and nginx dir permission fixes 2026-02-14 17:47:26 +01:00
1cea82f5d9 Merge pull request 'Update frontend to use unprivileged Nginx on port 8080' (#34) from development into main
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 21s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m33s
Reviewed-on: #34
2026-02-14 16:18:34 +00:00
418034f639 Update NEXAPG_VERSION to 0.2.2
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Migration Safety / Alembic upgrade/downgrade safety (pull_request) Successful in 23s
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
Bumped the version from 0.2.1 to 0.2.2 in the configuration file. This likely reflects a new release or minor update to the application.
2026-02-14 17:17:57 +01:00
489dde812f Update frontend to use unprivileged Nginx on port 8080
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Switch from `nginx:1.29-alpine-slim` to `nginxinc/nginx-unprivileged:stable-alpine` for improved security by running as a non-root user. Changed the exposed port from 80 to 8080 in the configurations to reflect the unprivileged setup. Adjusted the `docker-compose.yml` and `nginx.conf` accordingly.
2026-02-14 17:13:18 +01:00
c2e4e614e0 Merge pull request 'CI cleanup: remove temporary Alpine smoke job, keep PG matrix on development, and keep Alpine backend default' (#33) from development into main
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 28s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m51s
Reviewed-on: #33
2026-02-14 16:00:57 +00:00
344071193c Update NEXAPG_VERSION to 0.2.1
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 9s
Migration Safety / Alembic upgrade/downgrade safety (pull_request) Successful in 20s
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 13s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 12s
Bumped the version from 0.2.0 to 0.2.1 to reflect recent changes or updates. This ensures the system aligns with the latest versioning conventions.
2026-02-14 16:58:31 +01:00
03118e59d7 Remove backend Alpine smoke (PG16) job from CI workflow
Some checks failed
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG18 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG16 smoke (push) Has been cancelled
The backend Alpine smoke test targeting PostgreSQL 16 was removed from the CI configuration. This cleanup simplifies the workflow by eliminating redundancy, as the functionality might be covered elsewhere or deemed unnecessary.
2026-02-14 16:58:10 +01:00
15fea78505 Update Python base image to Alpine version for backend
Some checks failed
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / Backend Alpine smoke (PG16) (push) Failing after 6s
This change switches the base image from "slim" to "alpine" to reduce the overall image size and improve security. The updated image is more lightweight and better suited for environments where optimization is critical.
2026-02-14 16:52:10 +01:00
89d3a39679 Add new features and enhancements to CI workflows and backend.
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / Backend Alpine smoke (PG16) (push) Successful in 44s
Enhanced CI workflows by adding an Alpine-based smoke test for the backend with PostgreSQL 16. Updated the Docker build process to support dynamic base images and added provenance, SBOM, and labels to Docker builds. Extended branch compatibility checks and refined backend configurations for broader usage scenarios.
2026-02-14 16:48:10 +01:00
f614eb1cf8 Merge pull request 'NX-10x: Reliability, error handling, runtime UX hardening, and migration safety gate (NX-101, NX-102, NX-103, NX-104)' (#32) from development into main
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (push) Successful in 19s
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m14s
Reviewed-on: #32
2026-02-14 15:28:44 +00:00
6de3100615 [NX-104 Issue] Filter out restrict/unrestrict lines in schema comparison.
All checks were successful
Migration Safety / Alembic upgrade/downgrade safety (pull_request) Successful in 22s
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 7s
Updated the pg_dump commands in the migration-safety workflow to use `sed` for removing restrict/unrestrict lines. This ensures consistent schema comparison by ignoring irrelevant metadata.
2026-02-14 16:23:05 +01:00
cbe1cf26fa [NX-104 Issue] Add migration safety CI workflow
Some checks failed
Migration Safety / Alembic upgrade/downgrade safety (pull_request) Failing after 30s
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 7s
Introduces a GitHub Actions workflow to ensure Alembic migrations are safe and reversible. The workflow validates schema consistency by testing upgrade and downgrade operations and comparing schemas before and after the roundtrip.
2026-02-14 16:07:36 +01:00
5c566cd90d [NX-103 Issue] Add offline state handling for unreachable targets
Introduced a mechanism to detect and handle when a target is unreachable, including a detailed offline state message with host and port information. Updated the UI to display a card notifying users of the target's offline status and styled the card accordingly in CSS.
2026-02-14 15:58:22 +01:00
1ad237d750 Optimize collector loop to account for actual execution time.
Previously, the loop did not consider the time spent on `collect_once`, potentially causing delays. By adjusting the sleep duration dynamically, the poll interval remains consistent as intended.
2026-02-14 15:50:31 +01:00
d9dfde1c87 [NX-102 Issue] Add exponential backoff with jitter for retry logic
Introduced an exponential backoff mechanism with a configurable base, max delay, and jitter factor to handle retries for target failures. This improves resilience by reducing the load during repeated failures and avoids synchronized retry storms. Additionally, stale target cleanup logic has been implemented to prevent unnecessary state retention.
2026-02-14 11:44:49 +01:00
117710cc0a [NX-101 Issue] Refactor error handling to use consistent API error format
Replaced all inline error messages with the standardized `api_error` helper for consistent error response formatting. This improves clarity, maintainability, and ensures uniform error structures across the application. Updated logging for collector failures to include error class and switched to warning level for target unreachable scenarios.
2026-02-14 11:30:56 +01:00
9aecbea68b Add consistent API error handling and documentation
Introduced standardized error response formats for API errors, including middleware for consistent request IDs and exception handlers. Updated the frontend to parse and process these error responses, and documented the error format in the README for reference.
2026-02-13 17:30:05 +01:00
cd91b20278 Merge pull request 'Replace python-jose with PyJWT and update its usage' (#6) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m27s
Reviewed-on: #6
2026-02-13 12:23:40 +00:00
fd9853957a Merge branch 'main' of https://git.nesterovic.cc/nessi/NexaPG into development
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 7s
2026-02-13 13:20:49 +01:00
9c68f11d74 Replace python-jose with PyJWT and update its usage.
Switched the dependency from `python-jose` to `PyJWT` to handle JWT encoding and decoding. Updated related code to use `PyJWT`'s `InvalidTokenError` instead of `JWTError`. Also bumped the application version from `0.1.7` to `0.1.8`.
2026-02-13 13:20:46 +01:00
6848a66d88 Merge pull request 'Update backend requirements - security hardening' (#5) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m32s
Reviewed-on: #5
2026-02-13 12:07:48 +00:00
a9a49eba4e Merge branch 'main' of https://git.nesterovic.cc/nessi/NexaPG into development
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 11s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
2026-02-13 13:01:26 +01:00
9ccde7ca37 Update backend requirements - security hardening 2026-02-13 13:01:22 +01:00
88c3345647 Merge pull request 'Use lighter base images for frontend containers' (#4) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m24s
Reviewed-on: #4
2026-02-13 11:43:59 +00:00
d9f3de9468 Use lighter base images for frontend containers
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
Switched Node.js and Nginx images from 'bookworm' to 'alpine' variants to reduce image size. Added `apk upgrade --no-cache` for updated Alpine packages in the Nginx container. This optimizes resource usage and enhances performance.
2026-02-13 11:26:52 +01:00
e62aaaf5a0 Merge pull request 'Update base images in Dockerfile to use bookworm variants' (#3) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 2m7s
Reviewed-on: #3
2026-02-13 10:20:20 +00:00
ef84273868 Update base images in Dockerfile to use bookworm variants
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
Replaced alpine with bookworm-slim for Node.js and nginx to bookworm. This ensures compatibility with the latest updates and improves consistency across images. Adjusted the health check command for nginx accordingly.
2026-02-13 11:15:17 +01:00
6c59b21088 Merge pull request 'Add first and last name fields for users' (#2) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 1m13s
Reviewed-on: #2
2026-02-13 10:09:02 +00:00
cd1795b9ff Add first and last name fields for users
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 12s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 11s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 9s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 10s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 11s
This commit introduces optional `first_name` and `last_name` fields to the user model, including database migrations, backend, and frontend support. It enhances user profiles, updates user creation and editing flows, and refines the UI to display full names where available.
2026-02-13 10:57:10 +01:00
e0242bc823 Refactor deployment process to use prebuilt Docker images
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Replaced local builds with prebuilt backend and frontend Docker images for simplified deployment. Updated documentation and Makefile to reflect the changes and added a bootstrap script for quick setup of deployment files. Removed deprecated `VITE_API_URL` variable and references to streamline the setup.
2026-02-13 10:43:34 +01:00
75f8106ca5 Merge pull request 'Merge Fixes and Technical changes from development into main branch' (#1) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 4m30s
Reviewed-on: #1
2026-02-13 09:13:04 +00:00
4e4f8ad5d4 Update NEXAPG version to 0.1.3
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
This increments the application version from 0.1.2 to 0.1.3. It likely reflects bug fixes, improvements, or minor feature additions.
2026-02-13 10:11:00 +01:00
5c5d51350f Fix stale refresh usage in QueryInsightsPage effect hooks
Replaced `refresh` with `useRef` to ensure the latest value is always used in async operations. Added cleanup logic to handle component unmounts during API calls, preventing state updates on unmounted components.
2026-02-13 10:06:56 +01:00
ba1559e790 Improve agentless mode messaging for host-level metrics
Updated the messaging and UI to clarify unavailability of host-level metrics, such as CPU, RAM, and disk space, in agentless mode. Added clear formatting and new functions to handle missing metrics gracefully in the frontend.
2026-02-13 10:01:24 +01:00
ab9d03be8a Add GitHub Actions workflow for Docker image release
This workflow automates building and publishing Docker images upon a release or manual trigger. It includes steps for version resolution, Docker Hub login, and caching to optimize builds for both backend and frontend images.
2026-02-13 09:55:08 +01:00
07a7236282 Add user password change functionality
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a backend API endpoint for changing user passwords with validation. Added a new "User Settings" page in the frontend to allow users to update their passwords, including a matching UI update for navigation and styles.
2026-02-13 09:32:54 +01:00
bd53bce231 Add service update notification and version check enhancements
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Introduced a front-end mechanism to notify users of available service updates and enhanced the service info page to reflect update status dynamically. Removed backend audit log writes for version checks to streamline operations and improve performance. Updated styling to visually highlight update notifications.
2026-02-13 09:24:53 +01:00
18d6289807 Remove configurable APP_VERSION and define it in code
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
The APP_VERSION variable is no longer configurable via the `.env` file or environment settings. Instead, the version is now hardcoded in `backend/app/core/config.py` as `NEXAPG_VERSION` and accessed through a property. This change simplifies version control and ensures consistency across deployments.
2026-02-13 09:07:10 +01:00
e24681332d Simplify upstream version check mechanism
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 10s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Updated the "Check for Updates" functionality to rely solely on the latest published release in the upstream repository. Removed redundant code for fetching tags and commits, enhancing maintainability and reducing complexity.
2026-02-13 09:01:42 +01:00
0445a72764 Add service information feature with version checks
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
This commit introduces a new "Service Information" section displaying runtime details, installed version, and update status for the NexaPG application. It includes backend API endpoints, database schema changes, and a corresponding frontend page that allows users to check for updates against the official repository. The `.env` example now includes an `APP_VERSION` variable, and related documentation has been updated.
2026-02-13 08:54:13 +01:00
fd24a3a548 Enable pg_stat_statements in PostgreSQL containers
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
This change modifies the GitHub Actions workflow to enable the `pg_stat_statements` extension in PostgreSQL service containers during tests. It ensures the required settings are applied and the database is properly restarted to reflect the changes, improving compatibility checks and diagnostics.
2026-02-13 08:27:52 +01:00
7619757ed5 Add dynamic loading of standard alert references
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Replaced hardcoded standard alert metadata with API-driven data. This change ensures the standard alert information is dynamically loaded from the backend, improving maintainability and scalability. Also adjusted the frontend to handle cases where no data is available.
2026-02-13 08:24:55 +01:00
45d2173d1e Add a standard alert reference table to AlertsPage.
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
This commit introduces a collapsible "Standard Alert Reference" section in the AlertsPage, detailing built-in alerts, their checks, and thresholds. Relevant styling for the table and notes has also been added to improve readability and layout. This aims to enhance user understanding of default monitoring parameters.
2026-02-12 19:05:41 +01:00
08ee35e25f Add handling for pg_stat_statements compatibility checks
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a new `_run_expect_failure` helper to manage cases where specific queries are expected to fail. Added smoke tests for pg_stat_statements, validating its behavior when unavailable, loaded, or enabled. Also extended connectivity checks and enhanced database discovery queries.
2026-02-12 17:07:56 +01:00
91642e745f Remove max-height and overflow styles from target list
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
The dashboard target list no longer restricts height or scroll behavior. These changes allow for better flexibility in displaying content within the list.
2026-02-12 17:03:04 +01:00
fa8958934f Add multi-database discovery and grouping features
Some checks are pending
PostgreSQL Compatibility Matrix / PG14 smoke (push) Waiting to run
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 28s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
This update introduces optional automatic discovery and onboarding of all databases on a PostgreSQL instance. It also enhances the frontend UI with grouped target display and navigation, making it easier to view and manage related databases. Additionally, new backend endpoints and logic ensure seamless integration of these features.
2026-02-12 16:54:22 +01:00
1b12c01366 Add Table of Contents to README.md
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
This update introduces a comprehensive Table of Contents to the README.md file. It enhances navigation and makes it easier for users to find relevant sections quickly.
2026-02-12 16:43:58 +01:00
4bc178b720 Update migration instructions in README
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Removed redundant migration step and clarified that migrations run automatically on backend container startup. Updated related sections for better clarity and consistency.
2026-02-12 16:42:23 +01:00
8e5a549c2c Add template variables display and SMTP settings UI updates
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
This commit introduces a new section for template variables in the Admin Users page, improving clarity by listing placeholders available for email templates. It also enhances the SMTP settings interface with clearer organization and additional features like clearing stored passwords. Associated styling updates include new visual elements for template variables and subcards.
2026-02-12 16:37:31 +01:00
c437e72c2b Add customizable email templates and remove alert recipients
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Replaced the fixed `alert_recipients` list with customizable subject and body templates for alerts and warnings. This allows for more flexible and dynamic email notifications using placeholder variables. Updated relevant backend and frontend components to support this feature.
2026-02-12 16:32:53 +01:00
e5a9acfa91 Add logo container and enhance login page styling
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a styled wrapper for the login page logo to improve its appearance and layout consistency. Adjusted logo dimensions and applied new visual effects like gradients, shadow, and borders for a more polished design.
2026-02-12 16:23:22 +01:00
1bab5cd16d Add UI previews and NexaPG logo to README
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
Updated the README to include a centralized NexaPG logo and detailed UI previews of key application sections such as dashboard, targets management, query insights, and others. Added new screenshot assets to the `docs/screenshots` directory to support the visual updates.
2026-02-12 16:18:19 +01:00
6f36f73f8e Update README with expanded features and setup guidelines
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Enhanced the README file to include additional key features of NexaPG, detailed setup instructions, and descriptions of its core functionalities. Improved sections on configuration, troubleshooting, and PostgreSQL compatibility guidance for better user onboarding.
2026-02-12 15:54:24 +01:00
7599b3742d Add rollback ratio alert tuning parameters
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 9s
Introduced new parameters to fine-tune rollback ratio alerts and reduce false positives on low-traffic databases. Adjusted evaluation logic to account for minimum rollback counts and transaction volumes, ensuring more reliable alert thresholds. Updated .env.example and descriptions for better configuration clarity.
2026-02-12 15:49:44 +01:00
ec05163a04 Add minimum total connection threshold for active ratio alerts
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced `ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS` to reduce false positives by requiring a minimum number of total connections before evaluating the active connection ratio. Updated the logic and description in relevant files for clarity and configurability.
2026-02-12 15:44:30 +01:00
918bb132ef Add logging for failure recovery and throttled error reporting.
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced `_failure_state` to track consecutive failures per target and `_failure_log_interval_seconds` to control logging frequency. Added logs for recovery when a target recovers and throttled error logs to reduce noise for recurring errors.
2026-02-12 15:41:11 +01:00
505b93be4f Add dropdown with toggle functionality to OwnerPicker
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a dropdown feature to the OwnerPicker component, allowing users to search or browse owners more effectively. The dropdown can be toggled open/closed and includes improved styling for better user experience. Added click-outside functionality to automatically close the dropdown when users interact elsewhere.
2026-02-12 15:36:09 +01:00
648ff07651 Add support for "from_name" field in email notifications
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
Introduced a new optional "from_name" attribute to email settings, allowing customization of the sender's display name in outgoing emails. Updated backend models, APIs, and front-end components to include and handle this field properly. This enhances email clarity and personalization for users.
2026-02-12 15:31:03 +01:00
ea26ef4d33 Add target owners and alert notification management.
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
This commit implements the addition of `target_owners` and `alert_notification_events` tables, enabling management of responsible users for targets. Backend and frontend components are updated to allow viewing, assigning, and notifying target owners about critical alerts via email.
2026-02-12 15:22:32 +01:00
7acfb498b4 Improve admin page structure and styling for clarity
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Updated the Admin Users Page by reorganizing sections, improving labels, and adding descriptions for better usability. Adjusted styles to enhance visual hierarchy and provide consistent spacing and formatting throughout.
2026-02-12 15:09:51 +01:00
51eece14c2 Add email notification settings management
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
Implemented backend and frontend support for managing SMTP settings for email notifications. Includes API endpoints, database migration, and UI integration for configuring and testing email alerts.
2026-02-12 15:05:21 +01:00
882ad2dca8 Add custom styles and spacer for Admin nav button
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a spacer above the admin navigation link to enhance sidebar organization. Updated the styles for the admin navigation button, including hover and active states, to improve clarity and visual feedback when interacting with the button.
2026-02-12 14:51:43 +01:00
35a76aaca6 Refactor PostgreSQL compatibility smoke test runner
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Refactored `_fetchrow_required` and introduced multiple helper functions (e.g., `_run_required_fetch`, `_run_optional`) to streamline query execution and improve readability. Organized the script into distinct sections for better maintainability: connectivity, collector, target endpoints, overview, and optional paths. This enhances clarity and ensures consistency in the smoke testing process.
2026-02-12 14:43:06 +01:00
ff6d7998c3 Add support for multiple PostgreSQL DSN candidates
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 17s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 19s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 18s
This update introduces `PG_DSN_CANDIDATES` for specifying multiple DSN options, improving compatibility and CI portability. The script now attempts connections sequentially using the provided candidates before falling back to single DSN or raising an error. Relevant updates to documentation and workflow configuration have also been made.
2026-02-12 14:36:07 +01:00
a0ba4e1314 Refactor rollback ratio calculation and thresholds.
Some checks failed
PostgreSQL Compatibility Matrix / PG14 smoke (push) Failing after 3m17s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Failing after 1m17s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG18 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG16 smoke (push) Has been cancelled
Introduced a new helper function `_rollback_ratio_recent` to calculate the rollback ratio over the last 15 minutes, ensuring meaningful evaluation only when a minimum transaction threshold is met. Adjusted warning and alert thresholds for rollback ratio and added a contextual metric for transaction volume in the past 15 minutes.
2026-02-12 14:23:53 +01:00
9eb94545a1 Add PostgreSQL compatibility matrix CI workflow
Some checks failed
PostgreSQL Compatibility Matrix / PG14 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG15 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG16 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG17 smoke (push) Has been cancelled
PostgreSQL Compatibility Matrix / PG18 smoke (push) Has been cancelled
Introduced a GitHub Actions workflow to verify compatibility with PostgreSQL versions 14 through 18. Implemented a smoke test script to check core database metrics and version-specific differences. Updated the README with details about the compatibility matrix and usage instructions for the script.
2026-02-12 14:20:27 +01:00
528a720329 Add support for new checkpointer statistics for PostgreSQL.
Introduced logic to check the existence of `pg_stat_checkpointer` and fetch corresponding statistics when available. This ensures compatibility with newer PostgreSQL versions while maintaining backward support using `pg_stat_bgwriter`.
2026-02-12 14:15:14 +01:00
55f5652572 Improve action button styling and structure across pages
Introduced a new design and layout for action buttons on AlertsPage, DashboardPage, and TargetsPage with consistent styles and added SVG icons. Updated styles.css to support these changes with reusable button classes for better maintainability and UI consistency.
2026-02-12 14:05:51 +01:00
d4f176c731 Optimize metric updates and disable line animation.
Introduced a utility function to detect changes in metric series to prevent unnecessary updates. Extended the update interval from 1s to 3s and disabled animations for line charts to improve performance.
2026-02-12 14:02:01 +01:00
7957052172 Add live mode toggle for real-time chart updates
Introduced a new "Live" button to enable real-time chart updates, refreshing data every second. Refactored data fetching to use `useRef` for `refresh` and updated styles for the live mode button, ensuring a seamless user experience.
2026-02-12 13:57:32 +01:00
5674f2ea45 Fix redundant CSS class usage and enhance toggle-field layout
Removed the unnecessary "field-full" class from toggle-field divs in TargetsPage.jsx to simplify the layout. Updated styles.css to refine the toggle-field alignment and introduced a max-width for better control of toggle-check elements.
2026-02-12 13:52:12 +01:00
a8b7d9f54a Enhance toggle fields styling and layout
Updated the "Query Insights Source" toggle components for better UI consistency and accessibility. Added new styles for toggle switches and improved layout alignment to ensure a cohesive design throughout the page.
2026-02-12 13:50:03 +01:00
2747e62ff8 Rename migration revision ID for clarity
The migration script's revision ID was adjusted to remove redundancy in the naming. This improves readability and ensures consistency with naming conventions.
2026-02-12 13:41:48 +01:00
712bec3fea Add support for pg_stat_statements configuration in Targets
This commit introduces a `use_pg_stat_statements` flag for targets, allowing users to enable or disable the use of `pg_stat_statements` for query insights. It includes database schema changes, backend logic, and UI updates to manage this setting in both creation and editing workflows.
2026-02-12 13:39:57 +01:00
839943d9fd Add navigation and smooth scrolling for alert toasts
This update enables opening specific alerts via toast buttons, utilizing `useNavigate` to redirect and auto-expand the corresponding alert on the Alerts page. Includes enhancements for toast dismissal with animations and adds new styles for smooth transitions and better user interaction.
2026-02-12 13:33:50 +01:00
606d113f34 Add alert toasts and optimize alert status handling
Introduced a toast notification system to display new alerts and warnings. Updated the handling of alert status by centralizing it in the auth context and removing redundant API calls from individual pages. Improved styling for better user experience with alert notifications.
2026-02-12 13:28:01 +01:00
2c727c361e Enhance alerts with actionable suggestions.
Added `buildAlertSuggestions` to generate tailored recommendations for various alert categories. Integrated these suggestions into the AlertsPage UI and styled them for clarity, improving user guidance on resolving issues.
2026-02-12 13:21:24 +01:00
c74461ddfb Update padding and hover effect in styles.css
Revised table cell padding for better spacing and adjusted the hover effect on alert items to remove the slight upward translation. These changes aim to improve the UI consistency and user experience.
2026-02-12 13:16:58 +01:00
d0e8154c21 Improve alert handling and UI for Alerts and Dashboard pages
Added expandable alert details to the Alerts page, providing more insights into each warning or alert. Enhanced the Dashboard to display distinct counts for warnings and alerts, along with updated KPIs and improved styling for better visual hierarchy. These changes improve usability and clarity in monitoring alert statuses.
2026-02-12 13:01:08 +01:00
4035335901 Add alert management functionality in backend and frontend
This commit introduces alert management capabilities, including creating, updating, listing, and removing custom SQL-based alerts in the backend. It adds the necessary database migrations, API endpoints, and frontend pages to manage alerts, enabling users to define thresholds and monitor system health effectively.
2026-02-12 12:50:11 +01:00
d76a838bbb Update README with server IP and domain configuration
Replaced `localhost` references with `<SERVER_IP>` or custom domain options. This clarifies instructions for setting up the application on a remote server.
2026-02-12 12:34:49 +01:00
c42504beee Adjust button and card styles for improved UI consistency
Updated padding, borders, background gradients, and shadows in buttons and cards for a cleaner and more visually cohesive design. Tweaked hover effects to better align with the overall aesthetic.
2026-02-12 12:31:48 +01:00
c6da398574 Add search functionality to the Dashboard targets list
Implemented a search input to filter targets based on name, host, or database fields. Updated the UI to show filtered results and display a message if no targets match the search. Adjusted styles for improved responsiveness and usability.
2026-02-12 12:27:53 +01:00
afd30e3897 Improve UI styling and add visual enhancements to Dashboard
Added a subtitle and enhanced KPI card features with orbs and labels for better visual distinction. Updated card shadows, hover effects, fonts, and spacing across Dashboard elements to improve readability and user experience. Styles were streamlined for cleaner and modern aesthetics.
2026-02-12 12:19:21 +01:00
c191a67fa7 Revamp Dashboard UI and enhance target display.
Refactored the dashboard page to improve layout and clarity, including updated KPIs with better alert and status representation. Redesigned the target list to a card-based layout with clear status indicators and actionable buttons. Updated styles for consistency and better user experience.
2026-02-12 12:14:03 +01:00
c63e08748c Add search, pagination, and query tips to Query Insights.
This update introduces a search input for filtering queries and a pagination system for easier navigation of results. Query tips now provide optimization suggestions based on detected patterns and statistics, helping users identify and address performance issues more effectively. Styling adjustments were also made to improve the layout and user experience.
2026-02-12 12:09:47 +01:00
2400591f17 Add enhanced query insights UI with categorization
This update introduces a revamped Query Insights page with clear categorization and sorting based on optimization priority, execution frequency, and performance metrics. New features include query classification, compact SQL previews, and a detailed view for selected queries, improving user experience and actionable insights. Styling enhancements provide a more intuitive and visually appealing interface.
2026-02-12 12:05:41 +01:00
3e025bcf1b Add test connection feature for database targets
This commit introduces a new endpoint to test database connection. The frontend now includes a button to test the connection before creating a target, with real-time feedback on success or failure. Related styles and components were updated for better user experience.
2026-02-12 11:56:32 +01:00
2f5529a93a improve Targets Management 2026-02-12 11:44:57 +01:00
64b4c3dfa4 Add easy & DBA mode 2026-02-12 11:37:25 +01:00
d1af2bf4c6 Improve design and add logo 2026-02-12 11:32:10 +01:00
5b34c08851 Standardize English language usage and improve environment configuration
Replaced German text with English across the frontend UI for consistency and accessibility. Enhanced clarity in `.env.example` and `README.md`, adding detailed comments for environment variables and prerequisites. Improved documentation for setup, security, and troubleshooting.
2026-02-12 11:25:02 +01:00
6c660239d0 Refactor form structure and add collapsible components
Improved the user interface on the TargetsPage by replacing static form headers with collapsible sections, enhancing maintainability and user experience. Updated styles for consistency, added hover effects, and ensured accessibility. Also replaced German special characters for uniform encoding.
2026-02-12 11:18:15 +01:00
834c5b42b0 "Enhance TargetsPage styling and form structure"
Improved the layout and styling of the TargetsPage by adding structured HTML elements and new CSS classes. Key changes include adding headers to the target creation form, styling buttons with primary and danger classes, and improving the table hover effects. These updates enhance readability, usability, and visual consistency.
2026-02-12 11:14:38 +01:00
6e40d3c594 Enhance sidebar navigation icons with SVGs and update styling
Replaced text-based icons with accessible SVG icons for better visual appeal and improved semantics. Adjusted styles for sidebar buttons and icons, including dimensions, colors, and hover effects, to align with the updated design language. Added subtle animations and gradients for a more modern and polished user experience.
2026-02-12 11:09:08 +01:00
84 changed files with 13950 additions and 449 deletions

View File

@@ -1,29 +1,62 @@
# App
# ------------------------------
# Application
# ------------------------------
# Display name used in API docs/UI.
APP_NAME=NexaPG Monitor
# Runtime environment: dev | staging | prod | test
ENVIRONMENT=dev
# Backend log level: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
# Core DB
# ------------------------------
# Core Database (internal metadata DB)
# ------------------------------
# Database that stores users, targets, metrics, query stats, and audit logs.
# DEV default only. Use strong unique credentials in production.
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=nexapg
# Host port mapped to the internal PostgreSQL container port 5432.
DB_PORT=5433
# Backend
# ------------------------------
# Backend API
# ------------------------------
# Host port mapped to backend container port 8000.
BACKEND_PORT=8000
# JWT signing secret. Never hardcode in source. Rotate regularly.
JWT_SECRET_KEY=change_this_super_secret
JWT_ALGORITHM=HS256
# Access token lifetime in minutes.
JWT_ACCESS_TOKEN_MINUTES=15
# Refresh token lifetime in minutes (10080 = 7 days).
JWT_REFRESH_TOKEN_MINUTES=10080
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Key used to encrypt monitored target passwords at rest.
# Never hardcode in source. Rotate with re-encryption plan.
# Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
# Dev: set to * to allow all origins (credentials disabled automatically)
# Allowed CORS origins for browser clients.
# Use comma-separated values, e.g.:
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
# Dev-only shortcut:
# CORS_ORIGINS=*
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
# Target polling interval in seconds.
POLL_INTERVAL_SECONDS=30
# Active Connection Ratio alert is only evaluated when total sessions
# are at least this number (reduces false positives on low-traffic DBs).
ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5
# Rollback Ratio tuning to reduce false positives on low traffic.
ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15
ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100
ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10
# Initial admin bootstrap user (created on first startup if not present).
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# ------------------------------
# Frontend
# ------------------------------
# Host port mapped to frontend container port 8080.
FRONTEND_PORT=5173
# For reverse proxy + SSL prefer relative path to avoid mixed-content.
VITE_API_URL=/api/v1

View File

@@ -0,0 +1,112 @@
name: Container CVE Scan (development)
on:
push:
branches: ["development"]
workflow_dispatch:
jobs:
cve-scan:
name: Scan backend/frontend images for CVEs
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker Hub login (for Scout)
if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Prepare Docker auth config for Scout container
if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
run: |
mkdir -p "$RUNNER_TEMP/scout-docker-config"
cp "$HOME/.docker/config.json" "$RUNNER_TEMP/scout-docker-config/config.json"
chmod 600 "$RUNNER_TEMP/scout-docker-config/config.json"
- name: Build backend image (local)
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: false
load: true
tags: nexapg-backend:dev-scan
provenance: false
sbom: false
- name: Build frontend image (local)
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile
push: false
load: true
tags: nexapg-frontend:dev-scan
build-args: |
VITE_API_URL=/api/v1
provenance: false
sbom: false
- name: Docker Scout scan (backend)
continue-on-error: true
run: |
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
echo "Docker Hub Scout scan skipped: DOCKERHUB_USERNAME/DOCKERHUB_TOKEN not set." > scout-backend.txt
exit 0
fi
docker run --rm \
-u root \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$RUNNER_TEMP/scout-docker-config:/root/.docker" \
-e DOCKER_CONFIG=/root/.docker \
-e DOCKER_SCOUT_HUB_USER="${{ secrets.DOCKERHUB_USERNAME }}" \
-e DOCKER_SCOUT_HUB_PASSWORD="${{ secrets.DOCKERHUB_TOKEN }}" \
docker/scout-cli:latest cves nexapg-backend:dev-scan \
--only-severity critical,high,medium,low > scout-backend.txt 2>&1 || {
echo "" >> scout-backend.txt
echo "Docker Scout backend scan failed (non-blocking)." >> scout-backend.txt
}
- name: Docker Scout scan (frontend)
continue-on-error: true
run: |
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
echo "Docker Hub Scout scan skipped: DOCKERHUB_USERNAME/DOCKERHUB_TOKEN not set." > scout-frontend.txt
exit 0
fi
docker run --rm \
-u root \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$RUNNER_TEMP/scout-docker-config:/root/.docker" \
-e DOCKER_CONFIG=/root/.docker \
-e DOCKER_SCOUT_HUB_USER="${{ secrets.DOCKERHUB_USERNAME }}" \
-e DOCKER_SCOUT_HUB_PASSWORD="${{ secrets.DOCKERHUB_TOKEN }}" \
docker/scout-cli:latest cves nexapg-frontend:dev-scan \
--only-severity critical,high,medium,low > scout-frontend.txt 2>&1 || {
echo "" >> scout-frontend.txt
echo "Docker Scout frontend scan failed (non-blocking)." >> scout-frontend.txt
}
- name: Print scan summary
run: |
echo "===== Docker Scout backend ====="
test -f scout-backend.txt && cat scout-backend.txt || echo "scout-backend.txt not available"
echo
echo "===== Docker Scout frontend ====="
test -f scout-frontend.txt && cat scout-frontend.txt || echo "scout-frontend.txt not available"
- name: Upload scan reports
uses: actions/upload-artifact@v3
with:
name: container-cve-scan-reports
path: |
scout-backend.txt
scout-frontend.txt

139
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: Docker Publish (Release)
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version tag to publish (e.g. 0.1.2 or v0.1.2)"
required: false
type: string
jobs:
publish:
name: Build and Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
env:
# Optional repo variable. If unset, DOCKERHUB_USERNAME is used.
IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Dependency security gate (pip-audit)
run: |
python -m pip install --upgrade pip
pip install pip-audit
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
python backend/scripts/pip_audit_gate.py \
--report pip-audit-backend.json \
--allowlist ops/security/pip-audit-allowlist.json
- name: Resolve version/tag
id: ver
shell: bash
run: |
RAW_TAG="${{ github.event.release.tag_name }}"
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${{ inputs.version }}"
fi
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${GITHUB_REF_NAME}"
fi
CLEAN_TAG="${RAW_TAG#v}"
echo "raw=$RAW_TAG" >> "$GITHUB_OUTPUT"
echo "clean=$CLEAN_TAG" >> "$GITHUB_OUTPUT"
- name: Set image namespace
id: ns
shell: bash
run: |
NS="${IMAGE_NAMESPACE}"
if [ -z "$NS" ]; then
NS="${{ secrets.DOCKERHUB_USERNAME }}"
fi
# Normalize accidental input like spaces or uppercase.
NS="$(echo "$NS" | tr '[:upper:]' '[:lower:]' | xargs)"
# Reject clearly invalid placeholders/config mistakes early.
if [ -z "$NS" ] || [ "$NS" = "-" ]; then
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
exit 1
fi
# Namespace must be a single Docker Hub account/org name, not a path/url.
if [[ "$NS" == *"/"* ]] || [[ "$NS" == *":"* ]]; then
echo "Invalid Docker Hub namespace '$NS'. Use only the account/org name (e.g. 'nesterovicit')."
exit 1
fi
if ! [[ "$NS" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
echo "Invalid Docker Hub namespace '$NS'. Allowed: lowercase letters, digits, ., _, -"
exit 1
fi
echo "Using Docker Hub namespace: $NS"
echo "value=$NS" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: true
provenance: mode=max
sbom: true
labels: |
org.opencontainers.image.title=NexaPG Backend
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
tags: |
${{ steps.ns.outputs.value }}/nexapg-backend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-backend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache,mode=max
- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
provenance: mode=max
sbom: true
build-args: |
VITE_API_URL=/api/v1
labels: |
org.opencontainers.image.title=NexaPG Frontend
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
tags: |
${{ steps.ns.outputs.value }}/nexapg-frontend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-frontend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache,mode=max

110
.github/workflows/e2e-api-smoke.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: E2E API Smoke
on:
push:
branches: ["main", "master", "development"]
paths:
- "backend/**"
- ".github/workflows/e2e-api-smoke.yml"
pull_request:
paths:
- "backend/**"
- ".github/workflows/e2e-api-smoke.yml"
workflow_dispatch:
jobs:
e2e-smoke:
name: Core API E2E Smoke
runs-on: ubuntu-latest
env:
APP_NAME: NexaPG Monitor
ENVIRONMENT: test
LOG_LEVEL: INFO
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_NAME: nexapg
DB_USER: nexapg
DB_PASSWORD: nexapg
JWT_SECRET_KEY: smoke_jwt_secret_for_ci_only
JWT_ALGORITHM: HS256
JWT_ACCESS_TOKEN_MINUTES: 15
JWT_REFRESH_TOKEN_MINUTES: 10080
ENCRYPTION_KEY: 5fLf8HSTbEUeo1c4DnWnvkXxU6v8XJ8iW58wNw5vJ8s=
CORS_ORIGINS: http://localhost:5173
POLL_INTERVAL_SECONDS: 30
INIT_ADMIN_EMAIL: admin@example.com
INIT_ADMIN_PASSWORD: ChangeMe123!
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Start PostgreSQL container
run: |
docker rm -f nexapg-e2e-pg >/dev/null 2>&1 || true
docker run -d \
--name nexapg-e2e-pg \
-e POSTGRES_DB=nexapg \
-e POSTGRES_USER=nexapg \
-e POSTGRES_PASSWORD=nexapg \
-p 5432:5432 \
postgres:16
- name: Install backend dependencies + test tooling
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
pip install pytest
- name: Wait for PostgreSQL service
run: |
python - <<'PY'
import asyncio
import asyncpg
async def wait_for_db():
dsn = "postgresql://nexapg:nexapg@127.0.0.1:5432/nexapg?sslmode=disable"
last_err = None
for attempt in range(1, 61):
try:
conn = await asyncpg.connect(dsn=dsn, timeout=3)
try:
await conn.execute("SELECT 1")
finally:
await conn.close()
print(f"PostgreSQL ready after {attempt} attempt(s).")
return
except Exception as exc:
last_err = exc
await asyncio.sleep(2)
raise RuntimeError(f"PostgreSQL not ready after retries: {last_err}")
asyncio.run(wait_for_db())
PY
- name: Show PostgreSQL container status
if: ${{ always() }}
run: |
docker ps -a --filter "name=nexapg-e2e-pg"
docker logs --tail=80 nexapg-e2e-pg || true
- name: Run Alembic migrations
working-directory: backend
run: alembic upgrade head
- name: Run core API smoke suite
env:
PYTHONPATH: backend
run: pytest -q backend/tests/e2e/test_api_smoke.py
- name: Cleanup PostgreSQL container
if: ${{ always() }}
run: docker rm -f nexapg-e2e-pg >/dev/null 2>&1 || true

86
.github/workflows/migration-safety.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Migration Safety
on:
push:
branches: ["main", "master"]
pull_request:
jobs:
migration-safety:
name: Alembic upgrade/downgrade safety
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: nexapg
POSTGRES_USER: nexapg
POSTGRES_PASSWORD: nexapg
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U nexapg -d nexapg"
--health-interval 5s
--health-timeout 5s
--health-retries 30
env:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: nexapg
DB_USER: nexapg
DB_PASSWORD: nexapg
JWT_SECRET_KEY: ci-jwt-secret-key
ENCRYPTION_KEY: MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install backend dependencies
run: pip install -r backend/requirements.txt
- name: Install PostgreSQL client tools
run: sudo apt-get update && sudo apt-get install -y postgresql-client
- name: Wait for PostgreSQL
env:
PGPASSWORD: nexapg
run: |
for i in $(seq 1 60); do
if pg_isready -h postgres -p 5432 -U nexapg -d nexapg; then
exit 0
fi
sleep 2
done
echo "PostgreSQL did not become ready in time."
exit 1
- name: Alembic upgrade -> downgrade -1 -> upgrade
working-directory: backend
run: |
alembic upgrade head
alembic downgrade -1
alembic upgrade head
- name: Validate schema consistency after roundtrip
env:
PGPASSWORD: nexapg
run: |
cd backend
alembic upgrade head
pg_dump -h postgres -p 5432 -U nexapg -d nexapg --schema-only --no-owner --no-privileges \
| sed '/^\\restrict /d; /^\\unrestrict /d' > /tmp/schema_head_before.sql
alembic downgrade -1
alembic upgrade head
pg_dump -h postgres -p 5432 -U nexapg -d nexapg --schema-only --no-owner --no-privileges \
| sed '/^\\restrict /d; /^\\unrestrict /d' > /tmp/schema_head_after.sql
diff -u /tmp/schema_head_before.sql /tmp/schema_head_after.sql

72
.github/workflows/pg-compat-matrix.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: PostgreSQL Compatibility Matrix
on:
push:
branches: ["main", "master", "development"]
pull_request:
jobs:
pg-compat:
name: PG${{ matrix.pg_version }} smoke
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 3
matrix:
pg_version: ["14", "15", "16", "17", "18"]
services:
postgres:
image: postgres:${{ matrix.pg_version }}
env:
POSTGRES_DB: compatdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d compatdb"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install backend dependencies
run: pip install -r backend/requirements.txt
- name: Enable pg_stat_statements in service container
run: |
PG_CID="$(docker ps --filter "ancestor=postgres:${{ matrix.pg_version }}" --format "{{.ID}}" | head -n1)"
if [ -z "$PG_CID" ]; then
echo "Could not find postgres service container for version ${{ matrix.pg_version }}"
docker ps -a
exit 1
fi
echo "Using postgres container: $PG_CID"
docker exec "$PG_CID" psql -U postgres -d compatdb -c "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';"
docker restart "$PG_CID"
for i in $(seq 1 40); do
if docker exec "$PG_CID" pg_isready -U postgres -d compatdb; then
break
fi
sleep 2
done
docker exec "$PG_CID" psql -U postgres -d compatdb -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
- name: Run PostgreSQL compatibility smoke checks
env:
PG_DSN_CANDIDATES: postgresql://postgres:postgres@postgres:5432/compatdb?sslmode=disable,postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable
run: python backend/scripts/pg_compat_smoke.py

View File

@@ -0,0 +1,35 @@
name: Proxy Profile Validation
on:
push:
branches: ["main", "master", "development"]
paths:
- "frontend/**"
- "ops/profiles/prod/**"
- "ops/scripts/validate_proxy_profile.sh"
- ".github/workflows/proxy-profile-validation.yml"
- "README.md"
- ".env.example"
- "ops/.env.example"
pull_request:
paths:
- "frontend/**"
- "ops/profiles/prod/**"
- "ops/scripts/validate_proxy_profile.sh"
- ".github/workflows/proxy-profile-validation.yml"
- "README.md"
- ".env.example"
- "ops/.env.example"
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Validate proxy profile and mixed-content guardrails
run: bash ops/scripts/validate_proxy_profile.sh

View File

@@ -0,0 +1,53 @@
name: Python Dependency Security
on:
push:
branches: ["main", "master", "development"]
paths:
- "backend/**"
- ".github/workflows/python-dependency-security.yml"
- "ops/security/pip-audit-allowlist.json"
- "docs/security/dependency-exceptions.md"
pull_request:
paths:
- "backend/**"
- ".github/workflows/python-dependency-security.yml"
- "ops/security/pip-audit-allowlist.json"
- "docs/security/dependency-exceptions.md"
workflow_dispatch:
jobs:
pip-audit:
name: pip-audit (block high/critical)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pip-audit
run: |
python -m pip install --upgrade pip
pip install pip-audit
- name: Run pip-audit (JSON report)
run: |
pip-audit -r backend/requirements.txt --format json --aliases --output pip-audit-backend.json || true
- name: Enforce vulnerability policy
run: |
python backend/scripts/pip_audit_gate.py \
--report pip-audit-backend.json \
--allowlist ops/security/pip-audit-allowlist.json
- name: Upload pip-audit report
uses: actions/upload-artifact@v3
with:
name: pip-audit-security-report
path: pip-audit-backend.json

View File

@@ -1,7 +1,8 @@
.PHONY: up down logs migrate
up:
docker compose up -d --build
docker compose pull
docker compose up -d
down:
docker compose down

516
README.md
View File

@@ -1,113 +1,483 @@
# NexaPG - PostgreSQL Monitoring Stack
# NexaPG
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
<p align="center">
<img src="frontend/public/nexapg-logo.svg" alt="NexaPG Logo" width="180" />
</p>
## Features
NexaPG is a full-stack PostgreSQL monitoring platform for multiple remote targets.
It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, polling collectors, query insights, alerting, and target-owner email notifications.
- Multi-target PostgreSQL Monitoring (remote)
- Polling Collector fuer:
- `pg_stat_database`
- `pg_stat_activity`
- `pg_stat_bgwriter`
- `pg_locks`
- `pg_stat_statements` (falls auf Target aktiviert)
- Core-DB fuer:
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
- Targets (Credentials verschluesselt via Fernet)
- Metrics / Query Stats
- Audit Logs
- Auth mit JWT Access/Refresh Tokens
- FastAPI + SQLAlchemy async + Alembic
- React (Vite) Frontend mit:
- Login/Logout
- Dashboard
- Target Detail mit Charts
- Query Insights
- Admin User Management
- Health Endpoints:
- `/api/v1/healthz`
- `/api/v1/readyz`
## Table of Contents
## Struktur
- [Quick Deploy (Prebuilt Images)](#quick-deploy-prebuilt-images)
- [Prerequisites](#prerequisites)
- [Make Commands](#make-commands)
- [Configuration Reference (`.env`)](#configuration-reference-env)
- [Core Functional Areas](#core-functional-areas)
- [Service Information](#service-information)
- [Target Owner Notifications](#target-owner-notifications)
- [API Overview](#api-overview)
- [API Error Format](#api-error-format)
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
- [Production Proxy Profile](#production-proxy-profile)
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
- [E2E API Smoke Test](#e2e-api-smoke-test)
- [Dependency Exception Flow](#dependency-exception-flow)
- [Secret Management (Production)](#secret-management-production)
- [Troubleshooting](#troubleshooting)
- [Security Notes](#security-notes)
- `backend/` FastAPI App
- `frontend/` React (Vite) App
- `ops/` Scripts
- `docker-compose.yml` Stack
- `.env.example` Konfigurationsvorlage
## Highlights
## Schnellstart
- Multi-target monitoring for remote PostgreSQL instances
- Optional one-click target onboarding for "all databases" discovery on an instance
- PostgreSQL compatibility support: `14`, `15`, `16`, `17`, `18`
- JWT auth (`access` + `refresh`) and RBAC (`admin`, `operator`, `viewer`)
- Polling collector for metrics, locks, activity, and optional `pg_stat_statements`
- Target detail overview (instance, storage, replication, core performance metrics)
- Alerts system:
- standard built-in alerts
- custom SQL alerts (admin/operator)
- warning + alert severities
- real-time UI updates + toast notifications
- Target owners: alert emails are sent only to responsible users assigned to a target
- SMTP settings in admin UI (send-only) with test mail support
- Structured backend logs + audit logs
1. Env-Datei erstellen:
## UI Preview
### Dashboard
![Dashboard Overview](docs/screenshots/dashboard-overview.png)
### Targets Management
![Targets Management](docs/screenshots/targets-management.png)
### Query Insights
![Query Insights](docs/screenshots/query-insights.png)
### Alerts
![Alerts](docs/screenshots/alerts.png)
### Admin Settings
![Admin Settings](docs/screenshots/admin-settings.png)
### Target Detail (DBA Mode)
![Target Detail DBA](docs/screenshots/target-detail-dba.png)
### Target Detail (Easy Mode)
![Target Detail Easy](docs/screenshots/target-detail-easy.png)
## Repository Layout
- `backend/` FastAPI app, SQLAlchemy async models, Alembic migrations, collector services
- `frontend/` React + Vite UI
- `ops/` helper files/scripts
- `docker-compose.yml` full local stack
- `.env.example` complete environment template
- `Makefile` common commands
## Prerequisites
- Docker Engine `24+`
- Docker Compose `v2+`
- GNU Make (optional but recommended)
- Open host ports (or custom values in `.env`):
- `FRONTEND_PORT` (default `5173`)
- `BACKEND_PORT` (default `8000`)
- `DB_PORT` (default `5433`)
Optional:
- `psql` for manual DB checks
## Quick Deploy (Prebuilt Images)
If you only want to run NexaPG from published Docker Hub images, use the bootstrap script:
```bash
mkdir -p /opt/NexaPG
cd /opt/NexaPG
wget -O bootstrap-compose.sh https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/ops/scripts/bootstrap-compose.sh
chmod +x bootstrap-compose.sh
./bootstrap-compose.sh
```
This downloads:
- `docker-compose.yml`
- `.env.example`
- `Makefile`
Then:
```bash
# generate JWT secret
python -c "import secrets; print(secrets.token_urlsafe(64))"
# generate Fernet encryption key
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# put both values into .env (JWT_SECRET_KEY / ENCRYPTION_KEY)
# note: .env is auto-created by bootstrap if it does not exist
make up
```
Manual download alternative:
```bash
mkdir -p /opt/NexaPG
cd /opt/NexaPG
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/docker-compose.yml
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/.env.example
wget https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main/Makefile
cp .env.example .env
```
2. Fernet Key setzen:
`make up` pulls `nesterovicit/nexapg-backend:latest` and `nesterovicit/nexapg-frontend:latest`, then starts the stack.
Open the application:
- Frontend: `http://<SERVER_IP>:<FRONTEND_PORT>`
- API base: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
- OpenAPI: `http://<SERVER_IP>:<BACKEND_PORT>/docs`
Initial admin bootstrap user (created from `.env` if missing):
- Email: value from `INIT_ADMIN_EMAIL`
- Password: value from `INIT_ADMIN_PASSWORD`
## Make Commands
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
make up # pull latest images and start all services
make down # stop all services
make logs # follow compose logs
make migrate # optional/manual: run alembic upgrade head in backend container
```
Wert in `.env` bei `ENCRYPTION_KEY` eintragen.
Note: Migrations run automatically when the backend container starts (`entrypoint.sh`).
3. Stack starten:
## Configuration Reference (`.env`)
```bash
make up
```
### Application
4. URLs:
| Variable | Description |
|---|---|
| `APP_NAME` | Application display name |
| `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) |
| `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
- Frontend: `http://localhost:5173`
- Backend API: `http://localhost:8000/api/v1`
- OpenAPI: `http://localhost:8000/docs`
### Core Database
Default Admin (aus `.env`):
- Email: `admin@example.com`
- Passwort: `ChangeMe123!`
| Variable | Description |
|---|---|
| `DB_NAME` | Core metadata database name |
| `DB_USER` | Core database user |
| `DB_PASSWORD` | Core database password |
| `DB_PORT` | Host port mapped to internal PostgreSQL `5432` |
## Commands
### Backend / Security
```bash
make up
make down
make logs
make migrate
```
| Variable | Description |
|---|---|
| `BACKEND_PORT` | Host port mapped to backend container port `8000` |
| `JWT_SECRET_KEY` | JWT signing secret |
| `JWT_ALGORITHM` | JWT algorithm (default `HS256`) |
| `JWT_ACCESS_TOKEN_MINUTES` | Access token lifetime in minutes |
| `JWT_REFRESH_TOKEN_MINUTES` | Refresh token lifetime in minutes |
| `ENCRYPTION_KEY` | Fernet key for target credentials and SMTP password encryption |
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated or `*` for dev only) |
| `POLL_INTERVAL_SECONDS` | Collector polling interval |
| `INIT_ADMIN_EMAIL` | Bootstrap admin email |
| `INIT_ADMIN_PASSWORD` | Bootstrap admin password |
## API (Minimum)
### Alert Noise Tuning
| Variable | Description |
|---|---|
| `ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS` | Minimum total sessions required before evaluating active-connection ratio |
| `ALERT_ROLLBACK_RATIO_WINDOW_MINUTES` | Time window for rollback ratio evaluation |
| `ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS` | Minimum transaction volume before rollback ratio is evaluated |
| `ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS` | Minimum rollback count before rollback ratio is evaluated |
### Frontend
| Variable | Description |
|---|---|
| `FRONTEND_PORT` | Host port mapped to frontend container port `8080` |
## Core Functional Areas
### Targets
- Create, list, edit, delete targets
- Test target connection before save
- Optional "discover all databases" mode (creates one monitored target per discovered DB)
- Configure SSL mode per target
- Toggle `pg_stat_statements` usage per target
- Assign responsible users (target owners)
### Target Details
- Database Overview section with instance, role, uptime, size, replication, and core metrics
- Metric charts with range selection and live mode
- Locks and activity tables
### Query Insights
- Uses collected `pg_stat_statements` data
- Ranking and categorization views
- Search and pagination
- Disabled automatically for targets where query insights flag is off
### Alerts
- Warning and alert severity split
- Expandable alert cards with details and recommended actions
- Custom alert definitions (SQL + thresholds)
- Real-time refresh and in-app toast notifications
### Admin Settings
- User management (RBAC)
- SMTP settings for outgoing alert mails:
- enable/disable
- host/port/auth
- STARTTLS / SSL mode
- from email + from name
- recipient test mail
### Service Information
- Sidebar entry for runtime and system details
- Displays current version, latest known version, uptime, host, and platform
- "Check for Updates" against the latest published release in the official upstream repository (`git.nesterovic.cc/nessi/NexaPG`)
- Version/update source are read-only in UI (maintainer-controlled in code/release flow)
- Local displayed version is code-defined in `backend/app/core/config.py` (`NEXAPG_VERSION`) and not configurable via `.env`
## Target Owner Notifications
Email alert routing is target-specific:
- only users assigned as owners for a target receive that target's alert emails
- supports multiple owners per target
- notification sending is throttled to reduce repeated alert spam
## API Overview
### Health
- `GET /api/v1/healthz`
- `GET /api/v1/readyz`
### Auth
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `POST /api/v1/auth/logout`
- `GET /api/v1/me`
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
### Targets
- `GET /api/v1/targets`
- `POST /api/v1/targets`
- `POST /api/v1/targets/test-connection`
- `GET /api/v1/targets/{id}`
- `PUT /api/v1/targets/{id}`
- `DELETE /api/v1/targets/{id}`
- `GET /api/v1/targets/{id}/owners`
- `PUT /api/v1/targets/{id}/owners`
- `GET /api/v1/targets/owner-candidates`
- `GET /api/v1/targets/{id}/metrics`
- `GET /api/v1/targets/{id}/locks`
- `GET /api/v1/targets/{id}/activity`
- `GET /api/v1/targets/{id}/top-queries`
- Admin-only CRUD users:
- `GET /api/v1/admin/users`
- `POST /api/v1/admin/users`
- `PUT /api/v1/admin/users/{user_id}`
- `DELETE /api/v1/admin/users/{user_id}`
- `GET /api/v1/targets/{id}/overview`
## Security Notes
### Alerts
- Keine Secrets hardcoded
- Passwoerter als Argon2 Hash
- Target-Credentials verschluesselt (Fernet)
- CORS via Env steuerbar
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
- `GET /api/v1/alerts/status`
- `GET /api/v1/alerts/definitions`
- `POST /api/v1/alerts/definitions`
- `PUT /api/v1/alerts/definitions/{id}`
- `DELETE /api/v1/alerts/definitions/{id}`
- `POST /api/v1/alerts/definitions/test`
## Wichtiger Hinweis zu `pg_stat_statements`
### Admin
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
Beispiel:
- `GET /api/v1/admin/users`
- `POST /api/v1/admin/users`
- `PUT /api/v1/admin/users/{user_id}`
- `DELETE /api/v1/admin/users/{user_id}`
- `GET /api/v1/admin/settings/email`
- `PUT /api/v1/admin/settings/email`
- `POST /api/v1/admin/settings/email/test`
### Service Information
- `GET /api/v1/service/info`
- `POST /api/v1/service/info/check`
## API Error Format
All 4xx/5xx responses use a consistent JSON payload:
```json
{
"code": "validation_error",
"message": "Request validation failed",
"details": [],
"request_id": "c8f0f888-2365-4b86-a5de-b3f0e9df4a4b"
}
```
Common fields:
- `code`: stable machine-readable error code
- `message`: human-readable summary
- `details`: optional extra context (validation list, debug context, etc.)
- `request_id`: request correlation ID (also returned in `X-Request-ID` header)
Common error codes:
- `bad_request` (`400`)
- `unauthorized` (`401`)
- `forbidden` (`403`)
- `not_found` (`404`)
- `conflict` (`409`)
- `validation_error` (`422`)
- `target_unreachable` (`503`)
- `internal_error` (`500`)
## `pg_stat_statements` Requirement
Query Insights requires `pg_stat_statements` on the monitored target:
```sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```
If unavailable, disable it per target in target settings.
## Reverse Proxy / SSL Guidance
For production, serve frontend and API under the same public origin via reverse proxy.
- Frontend URL example: `https://monitor.example.com`
- Proxy API path `/api/` to backend service
- Route `/api/v1` to the backend service
This prevents mixed-content and CORS issues.
## Production Proxy Profile
A secure, repeatable production profile is included:
- `ops/profiles/prod/.env.production.example`
- `ops/profiles/prod/nginx/nexapg.conf`
- `docs/deployment/proxy-production-profile.md`
Highlights:
- explicit CORS recommendations per environment (`dev`, `staging`, `prod`)
- required reverse-proxy header forwarding for backend context
- API path forwarding (`/api/` -> backend)
- mixed-content prevention guidance for HTTPS deployments
## PostgreSQL Compatibility Smoke Test
Run manually against one DSN:
```bash
PG_DSN='postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
python backend/scripts/pg_compat_smoke.py
```
Run with DSN candidates (CI style):
```bash
PG_DSN_CANDIDATES='postgresql://postgres:postgres@postgres:5432/compatdb?sslmode=disable,postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
python backend/scripts/pg_compat_smoke.py
```
## E2E API Smoke Test
Core API smoke suite covers:
- auth login + `/me`
- targets CRUD
- metrics access
- alerts status
- admin users CRUD
Run locally (with backend env vars set and DB migrated):
```bash
PYTHONPATH=backend pytest -q backend/tests/e2e/test_api_smoke.py
```
## Dependency Exception Flow
Python dependency vulnerabilities are enforced by CI via `pip-audit`.
- CI blocks unresolved `HIGH` and `CRITICAL` findings.
- Missing severity metadata is treated conservatively as `HIGH`.
- Temporary exceptions must be declared in `ops/security/pip-audit-allowlist.json`.
- Full process and required metadata are documented in:
- `docs/security/dependency-exceptions.md`
## Secret Management (Production)
Secret handling guidance is documented in:
- `docs/security/secret-management.md`
It includes:
- secure handling for `JWT_SECRET_KEY`, `ENCRYPTION_KEY`, `DB_PASSWORD`, and SMTP credentials
- clear **Do / Don't** rules
- recommended secret provider patterns (Vault/cloud/orchestrator/CI injection)
- practical rotation basics and operational checklist
## Troubleshooting
### Backend container keeps restarting during `make migrate`
Most common reason: failed migration. Check logs:
```bash
docker compose logs --tail=200 backend
docker compose logs --tail=200 db
```
### CORS or mixed-content issues behind SSL proxy
- Ensure proxy forwards `/api/` (or `/api/v1`) to backend
- Set correct frontend origin(s) in `CORS_ORIGINS`
### `rejected SSL upgrade` for a target
Target likely does not support SSL with current settings.
Set target `sslmode` to `disable` (or correct SSL config on target DB).
### Query Insights empty
- Check target has `Use pg_stat_statements` enabled
- Verify extension exists on target (`CREATE EXTENSION ...`)
## Security Notes
- No secrets hardcoded in repository
- Passwords hashed with Argon2
- Sensitive values encrypted at rest (Fernet)
- RBAC enforced on protected endpoints
- Audit logs for critical actions
- Collector error logging includes throttling to reduce repeated noise
- Production secret handling and rotation guidance:
- `docs/security/secret-management.md`

View File

@@ -1,4 +1,5 @@
FROM python:3.12-slim AS base
ARG PYTHON_BASE_IMAGE=python:3.13-alpine
FROM ${PYTHON_BASE_IMAGE} AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
@@ -6,7 +7,17 @@ ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
RUN if command -v apt-get >/dev/null 2>&1; then \
apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*; \
elif command -v apk >/dev/null 2>&1; then \
apk upgrade --no-cache; \
fi
RUN if addgroup --help 2>&1 | grep -q -- '--system'; then \
addgroup --system app && adduser --system --ingroup app app; \
else \
addgroup -S app && adduser -S -G app app; \
fi
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip && pip install -r /app/requirements.txt

View 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")

View File

@@ -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")

View 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")

View 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")

View 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")

View 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")

View 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")

View File

@@ -0,0 +1,26 @@
"""add user first and last name fields
Revision ID: 0009_user_profile_fields
Revises: 0008_service_settings
Create Date: 2026-02-13
"""
from alembic import op
import sqlalchemy as sa
revision = "0009_user_profile_fields"
down_revision = "0008_service_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("first_name", sa.String(length=120), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(length=120), nullable=True))
def downgrade() -> None:
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")

View File

@@ -1,9 +1,12 @@
from fastapi import APIRouter
from app.api.routes import admin_users, auth, health, me, targets
from app.api.routes import admin_settings, admin_users, alerts, auth, health, me, service_info, targets
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(me.router, tags=["auth"])
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(service_info.router, prefix="/service", tags=["service"])
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])

View File

@@ -0,0 +1,136 @@
from email.message import EmailMessage
from email.utils import formataddr
import smtplib
import ssl
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import require_roles
from app.core.errors import api_error
from app.models.models import EmailNotificationSettings, User
from app.schemas.admin_settings import EmailSettingsOut, EmailSettingsTestRequest, EmailSettingsUpdate
from app.services.audit import write_audit_log
from app.services.crypto import decrypt_secret, encrypt_secret
router = APIRouter()
async def _get_or_create_settings(db: AsyncSession) -> EmailNotificationSettings:
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
if settings:
return settings
settings = EmailNotificationSettings()
db.add(settings)
await db.commit()
await db.refresh(settings)
return settings
def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
return EmailSettingsOut(
enabled=settings.enabled,
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
smtp_username=settings.smtp_username,
from_name=settings.from_name,
from_email=settings.from_email,
use_starttls=settings.use_starttls,
use_ssl=settings.use_ssl,
warning_subject_template=settings.warning_subject_template,
alert_subject_template=settings.alert_subject_template,
warning_body_template=settings.warning_body_template,
alert_body_template=settings.alert_body_template,
has_password=bool(settings.encrypted_smtp_password),
updated_at=settings.updated_at,
)
@router.get("/email", response_model=EmailSettingsOut)
async def get_email_settings(
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> EmailSettingsOut:
_ = admin
settings = await _get_or_create_settings(db)
return _to_out(settings)
@router.put("/email", response_model=EmailSettingsOut)
async def update_email_settings(
payload: EmailSettingsUpdate,
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> EmailSettingsOut:
settings = await _get_or_create_settings(db)
settings.enabled = payload.enabled
settings.smtp_host = payload.smtp_host.strip() if payload.smtp_host else None
settings.smtp_port = payload.smtp_port
settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None
settings.from_name = payload.from_name.strip() if payload.from_name else None
settings.from_email = str(payload.from_email) if payload.from_email else None
settings.use_starttls = payload.use_starttls
settings.use_ssl = payload.use_ssl
settings.warning_subject_template = payload.warning_subject_template.strip() if payload.warning_subject_template else None
settings.alert_subject_template = payload.alert_subject_template.strip() if payload.alert_subject_template else None
settings.warning_body_template = payload.warning_body_template.strip() if payload.warning_body_template else None
settings.alert_body_template = payload.alert_body_template.strip() if payload.alert_body_template else None
if payload.clear_smtp_password:
settings.encrypted_smtp_password = None
elif payload.smtp_password:
settings.encrypted_smtp_password = encrypt_secret(payload.smtp_password)
await db.commit()
await db.refresh(settings)
await write_audit_log(db, "admin.email_settings.update", admin.id, {"enabled": settings.enabled})
return _to_out(settings)
@router.post("/email/test")
async def test_email_settings(
payload: EmailSettingsTestRequest,
admin: User = Depends(require_roles("admin")),
db: AsyncSession = Depends(get_db),
) -> dict:
settings = await _get_or_create_settings(db)
if not settings.smtp_host:
raise HTTPException(status_code=400, detail=api_error("smtp_host_missing", "SMTP host is not configured"))
if not settings.from_email:
raise HTTPException(status_code=400, detail=api_error("smtp_from_email_missing", "From email is not configured"))
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
message = EmailMessage()
message["From"] = formataddr((settings.from_name, settings.from_email)) if settings.from_name else settings.from_email
message["To"] = str(payload.recipient)
message["Subject"] = payload.subject
message.set_content(payload.message)
try:
if settings.use_ssl:
with smtplib.SMTP_SSL(
settings.smtp_host,
settings.smtp_port,
timeout=10,
context=ssl.create_default_context(),
) as smtp:
if settings.smtp_username:
smtp.login(settings.smtp_username, password or "")
smtp.send_message(message)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
if settings.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if settings.smtp_username:
smtp.login(settings.smtp_username, password or "")
smtp.send_message(message)
except Exception as exc:
raise HTTPException(
status_code=400,
detail=api_error("smtp_test_failed", "SMTP test failed", {"error": str(exc)}),
) from exc
await write_audit_log(db, "admin.email_settings.test", admin.id, {"recipient": str(payload.recipient)})
return {"status": "sent", "recipient": str(payload.recipient)}

View File

@@ -3,6 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import require_roles
from app.core.errors import api_error
from app.core.security import hash_password
from app.models.models import User
from app.schemas.user import UserCreate, UserOut, UserUpdate
@@ -22,8 +23,14 @@ async def list_users(admin: User = Depends(require_roles("admin")), db: AsyncSes
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
exists = await db.scalar(select(User).where(User.email == payload.email))
if exists:
raise HTTPException(status_code=409, detail="Email already exists")
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role)
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
user = User(
email=payload.email,
first_name=payload.first_name,
last_name=payload.last_name,
password_hash=hash_password(payload.password),
role=payload.role,
)
db.add(user)
await db.commit()
await db.refresh(user)
@@ -40,10 +47,17 @@ async def update_user(
) -> UserOut:
user = await db.scalar(select(User).where(User.id == user_id))
if not user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
update_data = payload.model_dump(exclude_unset=True)
if "password" in update_data and update_data["password"]:
user.password_hash = hash_password(update_data.pop("password"))
next_email = update_data.get("email")
if next_email and next_email != user.email:
existing = await db.scalar(select(User).where(User.email == next_email))
if existing and existing.id != user.id:
raise HTTPException(status_code=409, detail=api_error("email_exists", "Email already exists"))
if "password" in update_data:
raw_password = update_data.pop("password")
if raw_password:
user.password_hash = hash_password(raw_password)
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
@@ -55,10 +69,10 @@ async def update_user(
@router.delete("/{user_id}")
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
raise HTTPException(status_code=400, detail=api_error("cannot_delete_self", "Cannot delete yourself"))
user = await db.scalar(select(User).where(User.id == user_id))
if not user:
raise HTTPException(status_code=404, detail="User not found")
raise HTTPException(status_code=404, detail=api_error("user_not_found", "User not found"))
await db.delete(user)
await db.commit()
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})

View File

@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user, require_roles
from app.core.errors import api_error
from app.models.models import AlertDefinition, Target, User
from app.schemas.alert import (
AlertDefinitionCreate,
AlertDefinitionOut,
AlertDefinitionTestRequest,
AlertDefinitionTestResponse,
AlertDefinitionUpdate,
AlertStatusResponse,
StandardAlertReferenceItem,
)
from app.services.alerts import (
get_standard_alert_reference,
get_alert_status,
invalidate_alert_cache,
run_scalar_sql_for_target,
validate_alert_sql,
validate_alert_thresholds,
)
from app.services.alert_notifications import process_target_owner_notifications
from app.services.audit import write_audit_log
router = APIRouter()
async def _validate_target_exists(db: AsyncSession, target_id: int | None) -> None:
if target_id is None:
return
target_exists = await db.scalar(select(Target.id).where(Target.id == target_id))
if target_exists is None:
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
@router.get("/status", response_model=AlertStatusResponse)
async def list_alert_status(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AlertStatusResponse:
_ = user
payload = await get_alert_status(db, use_cache=True)
await process_target_owner_notifications(db, payload)
return payload
@router.get("/standard-reference", response_model=list[StandardAlertReferenceItem])
async def list_standard_alert_reference(
user: User = Depends(get_current_user),
) -> list[StandardAlertReferenceItem]:
_ = user
return [StandardAlertReferenceItem(**item) for item in get_standard_alert_reference()]
@router.get("/definitions", response_model=list[AlertDefinitionOut])
async def list_alert_definitions(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> list[AlertDefinitionOut]:
_ = user
defs = (await db.scalars(select(AlertDefinition).order_by(AlertDefinition.id.desc()))).all()
return [AlertDefinitionOut.model_validate(item) for item in defs]
@router.post("/definitions", response_model=AlertDefinitionOut, status_code=status.HTTP_201_CREATED)
async def create_alert_definition(
payload: AlertDefinitionCreate,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> AlertDefinitionOut:
await _validate_target_exists(db, payload.target_id)
sql_text = validate_alert_sql(payload.sql_text)
validate_alert_thresholds(payload.comparison, payload.warning_threshold, payload.alert_threshold)
definition = AlertDefinition(
name=payload.name,
description=payload.description,
target_id=payload.target_id,
sql_text=sql_text,
comparison=payload.comparison,
warning_threshold=payload.warning_threshold,
alert_threshold=payload.alert_threshold,
enabled=payload.enabled,
created_by_user_id=user.id,
)
db.add(definition)
await db.commit()
await db.refresh(definition)
invalidate_alert_cache()
await write_audit_log(db, "alert.definition.create", user.id, {"alert_definition_id": definition.id, "name": definition.name})
return AlertDefinitionOut.model_validate(definition)
@router.put("/definitions/{definition_id}", response_model=AlertDefinitionOut)
async def update_alert_definition(
definition_id: int,
payload: AlertDefinitionUpdate,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> AlertDefinitionOut:
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
if definition is None:
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
updates = payload.model_dump(exclude_unset=True)
if "target_id" in updates:
await _validate_target_exists(db, updates["target_id"])
if "sql_text" in updates and updates["sql_text"] is not None:
updates["sql_text"] = validate_alert_sql(updates["sql_text"])
comparison = updates.get("comparison", definition.comparison)
warning_threshold = updates.get("warning_threshold", definition.warning_threshold)
alert_threshold = updates.get("alert_threshold", definition.alert_threshold)
validate_alert_thresholds(comparison, warning_threshold, alert_threshold)
for key, value in updates.items():
setattr(definition, key, value)
await db.commit()
await db.refresh(definition)
invalidate_alert_cache()
await write_audit_log(db, "alert.definition.update", user.id, {"alert_definition_id": definition.id})
return AlertDefinitionOut.model_validate(definition)
@router.delete("/definitions/{definition_id}")
async def delete_alert_definition(
definition_id: int,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> dict:
definition = await db.scalar(select(AlertDefinition).where(AlertDefinition.id == definition_id))
if definition is None:
raise HTTPException(status_code=404, detail=api_error("alert_definition_not_found", "Alert definition not found"))
await db.delete(definition)
await db.commit()
invalidate_alert_cache()
await write_audit_log(db, "alert.definition.delete", user.id, {"alert_definition_id": definition_id})
return {"status": "deleted"}
@router.post("/definitions/test", response_model=AlertDefinitionTestResponse)
async def test_alert_definition(
payload: AlertDefinitionTestRequest,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> AlertDefinitionTestResponse:
_ = user
target = await db.scalar(select(Target).where(Target.id == payload.target_id))
if target is None:
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
try:
value = await run_scalar_sql_for_target(target, payload.sql_text)
return AlertDefinitionTestResponse(ok=True, value=value)
except Exception as exc:
return AlertDefinitionTestResponse(ok=False, error=str(exc))

View File

@@ -1,10 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, status
from jose import JWTError, jwt
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.db import get_db
from app.core.deps import get_current_user
from app.core.errors import api_error
from app.core.security import create_access_token, create_refresh_token, verify_password
from app.models.models import User
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
@@ -19,7 +20,10 @@ settings = get_settings()
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
user = await db.scalar(select(User).where(User.email == payload.email))
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("invalid_credentials", "Invalid credentials"),
)
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
@@ -29,15 +33,24 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> To
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
try:
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("invalid_refresh_token", "Invalid refresh token"),
) from exc
if token_payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("invalid_refresh_token_type", "Invalid refresh token type"),
)
user_id = token_payload.get("sub")
user = await db.scalar(select(User).where(User.id == int(user_id)))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("user_not_found", "User not found"),
)
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))

View File

@@ -1,7 +1,12 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.core.errors import api_error
from app.core.security import hash_password, verify_password
from app.models.models import User
from app.schemas.user import UserOut
from app.schemas.user import UserOut, UserPasswordChange
from app.services.audit import write_audit_log
router = APIRouter()
@@ -9,3 +14,27 @@ router = APIRouter()
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)) -> UserOut:
return UserOut.model_validate(user)
@router.post("/me/password")
async def change_password(
payload: UserPasswordChange,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=api_error("invalid_current_password", "Current password is incorrect"),
)
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=api_error("password_reuse_not_allowed", "New password must be different"),
)
user.password_hash = hash_password(payload.new_password)
await db.commit()
await write_audit_log(db, action="auth.password_change", user_id=user.id, payload={})
return {"status": "ok"}

View 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,
)

View File

@@ -1,14 +1,25 @@
from datetime import datetime
from uuid import uuid4
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user, require_roles
from app.models.models import Metric, QueryStat, Target, User
from app.core.errors import api_error
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
from app.schemas.metric import MetricOut, QueryStatOut
from app.schemas.overview import DatabaseOverviewOut
from app.schemas.target import TargetCreate, TargetOut, TargetUpdate
from app.schemas.target import (
TargetConnectionTestRequest,
TargetCreate,
TargetOut,
TargetOwnerOut,
TargetOwnersUpdate,
TargetUpdate,
)
from app.services.audit import write_audit_log
from app.services.collector import build_target_dsn
from app.services.crypto import encrypt_secret
@@ -17,10 +28,120 @@ from app.services.overview_service import get_target_overview
router = APIRouter()
async def _owners_by_target_ids(db: AsyncSession, target_ids: list[int]) -> dict[int, list[int]]:
if not target_ids:
return {}
rows = (
await db.execute(select(TargetOwner.target_id, TargetOwner.user_id).where(TargetOwner.target_id.in_(target_ids)))
).all()
mapping: dict[int, list[int]] = {target_id: [] for target_id in target_ids}
for target_id, user_id in rows:
mapping.setdefault(target_id, []).append(user_id)
return mapping
def _target_out_with_owners(target: Target, owner_user_ids: list[int]) -> TargetOut:
return TargetOut(
id=target.id,
name=target.name,
host=target.host,
port=target.port,
dbname=target.dbname,
username=target.username,
sslmode=target.sslmode,
use_pg_stat_statements=target.use_pg_stat_statements,
owner_user_ids=owner_user_ids,
tags=target.tags or {},
created_at=target.created_at,
)
async def _set_target_owners(db: AsyncSession, target_id: int, user_ids: list[int], assigned_by_user_id: int | None) -> None:
await db.execute(delete(TargetOwner).where(TargetOwner.target_id == target_id))
for user_id in sorted(set(user_ids)):
db.add(TargetOwner(target_id=target_id, user_id=user_id, assigned_by_user_id=assigned_by_user_id))
async def _discover_databases(payload: TargetCreate) -> list[str]:
ssl = False if payload.sslmode == "disable" else True
conn = None
try:
conn = await asyncpg.connect(
host=payload.host,
port=payload.port,
database=payload.dbname,
user=payload.username,
password=payload.password,
ssl=ssl,
timeout=8,
)
rows = await conn.fetch(
"""
SELECT datname
FROM pg_database
WHERE datallowconn
AND NOT datistemplate
ORDER BY datname
"""
)
return [row["datname"] for row in rows if row["datname"]]
except Exception as exc:
raise HTTPException(
status_code=400,
detail=api_error("database_discovery_failed", "Database discovery failed", {"error": str(exc)}),
)
finally:
if conn:
await conn.close()
async def _next_unique_target_name(db: AsyncSession, base_name: str) -> str:
candidate = base_name.strip()
suffix = 2
while True:
exists = await db.scalar(select(Target.id).where(Target.name == candidate))
if exists is None:
return candidate
candidate = f"{base_name} ({suffix})"
suffix += 1
@router.get("", response_model=list[TargetOut])
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
_ = user
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
return [TargetOut.model_validate(item) for item in targets]
owner_map = await _owners_by_target_ids(db, [item.id for item in targets])
return [_target_out_with_owners(item, owner_map.get(item.id, [])) for item in targets]
@router.post("/test-connection")
async def test_target_connection(
payload: TargetConnectionTestRequest,
user: User = Depends(require_roles("admin", "operator")),
) -> dict:
_ = user
ssl = False if payload.sslmode == "disable" else True
conn = None
try:
conn = await asyncpg.connect(
host=payload.host,
port=payload.port,
database=payload.dbname,
user=payload.username,
password=payload.password,
ssl=ssl,
timeout=8,
)
version = await conn.fetchval("SHOW server_version")
return {"ok": True, "message": "Connection successful", "server_version": version}
except Exception as exc:
raise HTTPException(
status_code=400,
detail=api_error("connection_test_failed", "Connection test failed", {"error": str(exc)}),
)
finally:
if conn:
await conn.close()
@router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
@@ -29,29 +150,122 @@ async def create_target(
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> TargetOut:
owner_ids = sorted(set(payload.owner_user_ids or []))
if owner_ids:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
if len(set(owners_exist)) != len(owner_ids):
raise HTTPException(
status_code=400,
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
)
encrypted_password = encrypt_secret(payload.password)
created_targets: list[Target] = []
if payload.discover_all_databases:
databases = await _discover_databases(payload)
if not databases:
raise HTTPException(
status_code=400,
detail=api_error("no_databases_discovered", "No databases discovered on target"),
)
group_id = str(uuid4())
base_tags = payload.tags or {}
for dbname in databases:
duplicate = await db.scalar(
select(Target.id).where(
Target.host == payload.host,
Target.port == payload.port,
Target.dbname == dbname,
Target.username == payload.username,
)
)
if duplicate is not None:
continue
target_name = await _next_unique_target_name(db, f"{payload.name} / {dbname}")
tags = {
**base_tags,
"monitor_mode": "all_databases",
"monitor_group_id": group_id,
"monitor_group_name": payload.name,
}
target = Target(
name=payload.name,
name=target_name,
host=payload.host,
port=payload.port,
dbname=dbname,
username=payload.username,
encrypted_password=encrypted_password,
sslmode=payload.sslmode,
use_pg_stat_statements=payload.use_pg_stat_statements,
tags=tags,
)
db.add(target)
await db.flush()
created_targets.append(target)
if owner_ids:
await _set_target_owners(db, target.id, owner_ids, user.id)
if not created_targets:
raise HTTPException(
status_code=400,
detail=api_error("all_discovered_databases_exist", "All discovered databases already exist as targets"),
)
await db.commit()
for item in created_targets:
await db.refresh(item)
await write_audit_log(
db,
"target.create.all_databases",
user.id,
{"base_name": payload.name, "created_count": len(created_targets), "host": payload.host, "port": payload.port},
)
owner_map = await _owners_by_target_ids(db, [created_targets[0].id])
return _target_out_with_owners(created_targets[0], owner_map.get(created_targets[0].id, []))
target_name = await _next_unique_target_name(db, payload.name)
target = Target(
name=target_name,
host=payload.host,
port=payload.port,
dbname=payload.dbname,
username=payload.username,
encrypted_password=encrypt_secret(payload.password),
encrypted_password=encrypted_password,
sslmode=payload.sslmode,
use_pg_stat_statements=payload.use_pg_stat_statements,
tags=payload.tags,
)
db.add(target)
await db.commit()
await db.refresh(target)
if owner_ids:
await _set_target_owners(db, target.id, owner_ids, user.id)
await db.commit()
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
return TargetOut.model_validate(target)
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.get("/owner-candidates", response_model=list[TargetOwnerOut])
async def list_owner_candidates(
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
_ = user
users = (await db.scalars(select(User).order_by(User.email.asc()))).all()
return [TargetOwnerOut(user_id=item.id, email=item.email, role=item.role) for item in users]
@router.get("/{target_id}", response_model=TargetOut)
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
return TargetOut.model_validate(target)
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.put("/{target_id}", response_model=TargetOut)
@@ -63,17 +277,82 @@ async def update_target(
) -> TargetOut:
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
updates = payload.model_dump(exclude_unset=True)
owner_user_ids = updates.pop("owner_user_ids", None)
if "password" in updates:
target.encrypted_password = encrypt_secret(updates.pop("password"))
for key, value in updates.items():
setattr(target, key, value)
if owner_user_ids is not None:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
if len(set(owners_exist)) != len(set(owner_user_ids)):
raise HTTPException(
status_code=400,
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
)
await _set_target_owners(db, target.id, owner_user_ids, user.id)
await db.commit()
await db.refresh(target)
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
return TargetOut.model_validate(target)
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.put("/{target_id}/owners", response_model=list[TargetOwnerOut])
async def set_target_owners(
target_id: int,
payload: TargetOwnersUpdate,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
owner_user_ids = sorted(set(payload.user_ids))
if owner_user_ids:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
if len(set(owners_exist)) != len(set(owner_user_ids)):
raise HTTPException(
status_code=400,
detail=api_error("owner_users_not_found", "One or more owner users were not found"),
)
await _set_target_owners(db, target_id, owner_user_ids, user.id)
await db.commit()
await write_audit_log(db, "target.owners.update", user.id, {"target_id": target_id, "owner_user_ids": owner_user_ids})
rows = (
await db.execute(
select(User.id, User.email, User.role)
.join(TargetOwner, TargetOwner.user_id == User.id)
.where(TargetOwner.target_id == target_id)
.order_by(User.email.asc())
)
).all()
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
@router.get("/{target_id}/owners", response_model=list[TargetOwnerOut])
async def get_target_owners(
target_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
rows = (
await db.execute(
select(User.id, User.email, User.role)
.join(TargetOwner, TargetOwner.user_id == User.id)
.where(TargetOwner.target_id == target_id)
.order_by(User.email.asc())
)
).all()
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
@router.delete("/{target_id}")
@@ -84,7 +363,7 @@ async def delete_target(
) -> dict:
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
await db.delete(target)
await db.commit()
await write_audit_log(db, "target.delete", user.id, {"target_id": target_id})
@@ -112,7 +391,22 @@ async def get_metrics(
async def _live_conn(target: Target) -> asyncpg.Connection:
try:
return await asyncpg.connect(dsn=build_target_dsn(target))
except (OSError, asyncpg.PostgresError) as exc:
raise HTTPException(
status_code=503,
detail=api_error(
"target_unreachable",
"Target database is not reachable",
{
"target_id": target.id,
"host": target.host,
"port": target.port,
"error": str(exc),
},
),
) from exc
@router.get("/{target_id}/locks")
@@ -120,7 +414,7 @@ async def get_locks(target_id: int, user: User = Depends(get_current_user), db:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
conn = await _live_conn(target)
try:
rows = await conn.fetch(
@@ -141,7 +435,7 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
conn = await _live_conn(target)
try:
rows = await conn.fetch(
@@ -161,6 +455,11 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
if not target.use_pg_stat_statements:
return []
rows = (
await db.scalars(
select(QueryStat)
@@ -188,5 +487,20 @@ async def get_overview(target_id: int, user: User = Depends(get_current_user), d
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found"))
try:
return await get_target_overview(target)
except (OSError, asyncpg.PostgresError) as exc:
raise HTTPException(
status_code=503,
detail=api_error(
"target_unreachable",
"Target database is not reachable",
{
"target_id": target.id,
"host": target.host,
"port": target.port,
"error": str(exc),
},
),
) from exc

View File

@@ -2,6 +2,8 @@ from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
NEXAPG_VERSION = "0.2.5"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -25,9 +27,17 @@ class Settings(BaseSettings):
encryption_key: str
cors_origins: str = "http://localhost:5173"
poll_interval_seconds: int = 30
alert_active_connection_ratio_min_total_connections: int = 5
alert_rollback_ratio_window_minutes: int = 15
alert_rollback_ratio_min_total_transactions: int = 100
alert_rollback_ratio_min_rollbacks: int = 10
init_admin_email: str = "admin@example.com"
init_admin_password: str = "ChangeMe123!"
@property
def app_version(self) -> str:
return NEXAPG_VERSION
@property
def database_url(self) -> str:
return (

View File

@@ -1,10 +1,11 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.db import get_db
from app.core.errors import api_error
from app.models.models import User
settings = get_settings()
@@ -16,27 +17,42 @@ async def get_current_user(
db: AsyncSession = Depends(get_db),
) -> User:
if not credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("missing_token", "Missing token"),
)
token = credentials.credentials
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("invalid_token", "Invalid token"),
) from exc
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("invalid_token_type", "Invalid token type"),
)
user_id = payload.get("sub")
user = await db.scalar(select(User).where(User.id == int(user_id)))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=api_error("user_not_found", "User not found"),
)
return user
def require_roles(*roles: str):
async def role_dependency(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=api_error("forbidden", "Forbidden"),
)
return user
return role_dependency

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
def error_payload(code: str, message: str, details: Any, request_id: str) -> dict[str, Any]:
return {
"code": code,
"message": message,
"details": details,
"request_id": request_id,
}
def api_error(code: str, message: str, details: Any = None) -> dict[str, Any]:
return {
"code": code,
"message": message,
"details": details,
}
def http_status_to_code(status_code: int) -> str:
mapping = {
400: "bad_request",
401: "unauthorized",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
409: "conflict",
422: "validation_error",
429: "rate_limited",
500: "internal_error",
502: "bad_gateway",
503: "service_unavailable",
504: "gateway_timeout",
}
return mapping.get(status_code, f"http_{status_code}")

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from passlib.context import CryptContext
from app.core.config import get_settings

View File

@@ -1,12 +1,17 @@
import asyncio
import logging
from uuid import uuid4
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from sqlalchemy import select
from app.api.router import api_router
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.errors import error_payload, http_status_to_code
from app.core.logging import configure_logging
from app.core.security import hash_password
from app.models.models import User
@@ -57,4 +62,67 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
@app.exception_handler(HTTPException)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException):
request_id = getattr(request.state, "request_id", str(uuid4()))
code = http_status_to_code(exc.status_code)
message = "Request failed"
details = None
if isinstance(exc.detail, str):
message = exc.detail
elif isinstance(exc.detail, dict):
code = str(exc.detail.get("code", code))
message = str(exc.detail.get("message", message))
details = exc.detail.get("details")
elif isinstance(exc.detail, list):
message = "Request validation failed"
details = exc.detail
return JSONResponse(
status_code=exc.status_code,
content=error_payload(code=code, message=message, details=details, request_id=request_id),
)
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
request_id = getattr(request.state, "request_id", str(uuid4()))
return JSONResponse(
status_code=422,
content=error_payload(
code="validation_error",
message="Request validation failed",
details=exc.errors(),
request_id=request_id,
),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
request_id = getattr(request.state, "request_id", str(uuid4()))
logger.exception("unhandled_exception request_id=%s", request_id, exc_info=exc)
return JSONResponse(
status_code=500,
content=error_payload(
code="internal_error",
message="Internal server error",
details=None,
request_id=request_id,
),
)
app.include_router(api_router, prefix=settings.api_v1_prefix)

View File

@@ -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",
]

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base
@@ -9,11 +9,18 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
first_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
owned_targets: Mapped[list["TargetOwner"]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="TargetOwner.user_id",
)
class Target(Base):
@@ -27,11 +34,28 @@ class Target(Base):
username: Mapped[str] = mapped_column(String(120), nullable=False)
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
use_pg_stat_statements: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
alert_definitions: Mapped[list["AlertDefinition"]] = relationship(back_populates="target", cascade="all, delete-orphan")
owners: Mapped[list["TargetOwner"]] = relationship(back_populates="target", cascade="all, delete-orphan")
class TargetOwner(Base):
__tablename__ = "target_owners"
__table_args__ = (UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
target: Mapped[Target] = relationship(back_populates="owners")
user: Mapped[User] = relationship(foreign_keys=[user_id], back_populates="owned_targets")
class Metric(Base):
@@ -73,3 +97,84 @@ class AuditLog(Base):
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
user: Mapped[User | None] = relationship(back_populates="audit_logs")
class AlertDefinition(Base):
__tablename__ = "alert_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
target_id: Mapped[int | None] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=True, index=True)
sql_text: Mapped[str] = mapped_column(Text, nullable=False)
comparison: Mapped[str] = mapped_column(String(10), nullable=False, default="gte")
warning_threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
alert_threshold: Mapped[float] = mapped_column(Float, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
target: Mapped[Target | None] = relationship(back_populates="alert_definitions")
class EmailNotificationSettings(Base):
__tablename__ = "email_notification_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
from_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
warning_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
alert_subject_template: Mapped[str | None] = mapped_column(Text, nullable=True)
warning_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
alert_body_template: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
class ServiceInfoSettings(Base):
__tablename__ = "service_info_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
current_version: Mapped[str] = mapped_column(String(64), nullable=False, default="0.1.0")
release_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
latest_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
update_available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_check_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
class AlertNotificationEvent(Base):
__tablename__ = "alert_notification_events"
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
alert_key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())

View 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."

View 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

View 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

View File

@@ -9,11 +9,23 @@ class TargetBase(BaseModel):
dbname: str
username: str
sslmode: str = "prefer"
use_pg_stat_statements: bool = True
owner_user_ids: list[int] = Field(default_factory=list)
tags: dict = Field(default_factory=dict)
class TargetCreate(TargetBase):
password: str
discover_all_databases: bool = False
class TargetConnectionTestRequest(BaseModel):
host: str
port: int = 5432
dbname: str
username: str
password: str
sslmode: str = "prefer"
class TargetUpdate(BaseModel):
@@ -24,6 +36,8 @@ class TargetUpdate(BaseModel):
username: str | None = None
password: str | None = None
sslmode: str | None = None
use_pg_stat_statements: bool | None = None
owner_user_ids: list[int] | None = None
tags: dict | None = None
@@ -32,3 +46,13 @@ class TargetOut(TargetBase):
created_at: datetime
model_config = {"from_attributes": True}
class TargetOwnerOut(BaseModel):
user_id: int
email: str
role: str
class TargetOwnersUpdate(BaseModel):
user_ids: list[int] = Field(default_factory=list)

View File

@@ -1,10 +1,12 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
class UserOut(BaseModel):
id: int
email: EmailStr
first_name: str | None = None
last_name: str | None = None
role: str
created_at: datetime
@@ -13,11 +15,27 @@ class UserOut(BaseModel):
class UserCreate(BaseModel):
email: EmailStr
first_name: str | None = None
last_name: str | None = None
password: str
role: str = "viewer"
class UserUpdate(BaseModel):
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
password: str | None = None
role: str | None = None
class UserPasswordChange(BaseModel):
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def validate_new_password(cls, value: str) -> str:
if len(value) < 8:
raise ValueError("new_password must be at least 8 characters")
return value

View 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()

View File

@@ -0,0 +1,625 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import re
import time
import asyncpg
from fastapi import HTTPException
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.errors import api_error
from app.models.models import AlertDefinition, Metric, QueryStat, Target
from app.schemas.alert import AlertStatusItem, AlertStatusResponse
from app.services.collector import build_target_dsn
settings = get_settings()
_ALLOWED_COMPARISONS = {"gte", "gt", "lte", "lt"}
_FORBIDDEN_SQL_WORDS = re.compile(
r"\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|vacuum|analyze|copy|call|do)\b",
re.IGNORECASE,
)
_STATUS_CACHE_TTL_SECONDS = 15
_status_cache: dict = {"expires": 0.0, "data": None}
@dataclass
class _RuleInput:
key: str
name: str
description: str
category: str
value: float | None
warning_threshold: float | None
alert_threshold: float | None
comparison: str = "gte"
def invalidate_alert_cache() -> None:
_status_cache["expires"] = 0.0
_status_cache["data"] = None
def get_standard_alert_reference() -> list[dict[str, str]]:
return [
{
"key": "target_reachability",
"name": "Target Reachability",
"checks": "Connection to target database can be established.",
"comparison": "-",
"warning": "-",
"alert": "On connection failure",
},
{
"key": "connectivity_rtt_ms",
"name": "Connectivity Latency",
"checks": "Connection handshake duration (milliseconds).",
"comparison": "gte",
"warning": "1000 ms",
"alert": "2500 ms",
},
{
"key": "collector_freshness_seconds",
"name": "Collector Freshness",
"checks": "Age of newest metric sample.",
"comparison": "gte",
"warning": f"{settings.poll_interval_seconds * 2} s (poll interval x2)",
"alert": f"{settings.poll_interval_seconds * 4} s (poll interval x4)",
},
{
"key": "active_connections_ratio",
"name": "Active Connection Ratio",
"checks": (
"active_connections / total_connections "
f"(evaluated only when total sessions >= {settings.alert_active_connection_ratio_min_total_connections})."
),
"comparison": "gte",
"warning": "0.70",
"alert": "0.90",
},
{
"key": "cache_hit_ratio_low",
"name": "Cache Hit Ratio",
"checks": "Buffer cache efficiency (lower is worse).",
"comparison": "lte",
"warning": "0.95",
"alert": "0.90",
},
{
"key": "locks_total",
"name": "Lock Pressure",
"checks": "Current total lock count.",
"comparison": "gte",
"warning": "50",
"alert": "100",
},
{
"key": "checkpoints_req_15m",
"name": "Checkpoint Pressure (15m)",
"checks": "Increase of requested checkpoints in last 15 minutes.",
"comparison": "gte",
"warning": "5",
"alert": "15",
},
{
"key": "rollback_ratio",
"name": "Rollback Ratio",
"checks": (
f"rollback / (commit + rollback) in last {settings.alert_rollback_ratio_window_minutes} minutes "
f"(evaluated only when >= {settings.alert_rollback_ratio_min_total_transactions} transactions "
f"and >= {settings.alert_rollback_ratio_min_rollbacks} rollbacks)."
),
"comparison": "gte",
"warning": "0.10",
"alert": "0.25",
},
{
"key": "deadlocks_60m",
"name": "Deadlocks (60m)",
"checks": "Increase in deadlocks during last 60 minutes.",
"comparison": "gte",
"warning": "1",
"alert": "5",
},
{
"key": "slowest_query_mean_ms",
"name": "Slowest Query Mean Time",
"checks": "Highest query mean execution time in latest snapshot.",
"comparison": "gte",
"warning": "300 ms",
"alert": "1000 ms",
},
{
"key": "slowest_query_total_ms",
"name": "Slowest Query Total Time",
"checks": "Highest query total execution time in latest snapshot.",
"comparison": "gte",
"warning": "3000 ms",
"alert": "10000 ms",
},
]
def validate_alert_thresholds(comparison: str, warning_threshold: float | None, alert_threshold: float) -> None:
if comparison not in _ALLOWED_COMPARISONS:
raise HTTPException(
status_code=400,
detail=api_error(
"invalid_comparison",
f"Invalid comparison. Use one of {sorted(_ALLOWED_COMPARISONS)}",
),
)
if warning_threshold is None:
return
if comparison in {"gte", "gt"} and warning_threshold > alert_threshold:
raise HTTPException(
status_code=400,
detail=api_error("invalid_thresholds", "For gte/gt, warning_threshold must be <= alert_threshold"),
)
if comparison in {"lte", "lt"} and warning_threshold < alert_threshold:
raise HTTPException(
status_code=400,
detail=api_error("invalid_thresholds", "For lte/lt, warning_threshold must be >= alert_threshold"),
)
def validate_alert_sql(sql_text: str) -> str:
sql = sql_text.strip().rstrip(";")
lowered = sql.lower().strip()
if not lowered.startswith("select"):
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Alert SQL must start with SELECT"))
if _FORBIDDEN_SQL_WORDS.search(lowered):
raise HTTPException(
status_code=400,
detail=api_error("invalid_alert_sql", "Only read-only SELECT statements are allowed"),
)
if ";" in sql:
raise HTTPException(status_code=400, detail=api_error("invalid_alert_sql", "Only a single SQL statement is allowed"))
return sql
async def _connect_target(target: Target, timeout_seconds: int = 5) -> asyncpg.Connection:
return await asyncpg.connect(dsn=build_target_dsn(target), timeout=timeout_seconds)
async def run_scalar_sql_for_target(target: Target, sql_text: str) -> float:
sql = validate_alert_sql(sql_text)
conn: asyncpg.Connection | None = None
try:
conn = await _connect_target(target, timeout_seconds=6)
row = await conn.fetchrow(sql)
if not row:
raise ValueError("Query returned no rows")
value = row[0]
return float(value)
finally:
if conn:
await conn.close()
def _compare(value: float, threshold: float, comparison: str) -> bool:
if comparison == "gte":
return value >= threshold
if comparison == "gt":
return value > threshold
if comparison == "lte":
return value <= threshold
if comparison == "lt":
return value < threshold
return False
def _severity_from_thresholds(
value: float | None, comparison: str, warning_threshold: float | None, alert_threshold: float | None
) -> str:
if value is None or alert_threshold is None:
return "unknown"
if _compare(value, alert_threshold, comparison):
return "alert"
if warning_threshold is not None and _compare(value, warning_threshold, comparison):
return "warning"
return "ok"
def _status_message(value: float | None, comparison: str, warning_threshold: float | None, alert_threshold: float | None) -> str:
if value is None:
return "No numeric value available"
if alert_threshold is None:
return f"Current value: {value:.2f}"
if warning_threshold is None:
return f"Current value: {value:.2f} (alert when value {comparison} {alert_threshold:.2f})"
return (
f"Current value: {value:.2f} "
f"(warning when value {comparison} {warning_threshold:.2f}, alert when value {comparison} {alert_threshold:.2f})"
)
async def _latest_metric_value(db: AsyncSession, target_id: int, metric_name: str) -> float | None:
row = await db.scalar(
select(Metric.value)
.where(Metric.target_id == target_id, Metric.metric_name == metric_name)
.order_by(desc(Metric.ts))
.limit(1)
)
return float(row) if row is not None else None
async def _metric_delta(db: AsyncSession, target_id: int, metric_name: str, minutes: int) -> float | None:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
latest = await db.scalar(
select(Metric.value)
.where(Metric.target_id == target_id, Metric.metric_name == metric_name)
.order_by(desc(Metric.ts))
.limit(1)
)
oldest = await db.scalar(
select(Metric.value)
.where(Metric.target_id == target_id, Metric.metric_name == metric_name, Metric.ts >= cutoff)
.order_by(Metric.ts.asc())
.limit(1)
)
if latest is None or oldest is None:
return None
return max(0.0, float(latest) - float(oldest))
async def _rollback_ratio_recent(
db: AsyncSession, target_id: int, minutes: int, min_total_transactions: int, min_rollbacks: int
) -> tuple[float | None, float, float]:
commit_delta = await _metric_delta(db, target_id, "xact_commit", minutes=minutes)
rollback_delta = await _metric_delta(db, target_id, "xact_rollback", minutes=minutes)
if commit_delta is None or rollback_delta is None:
return None, 0.0, 0.0
tx_total = commit_delta + rollback_delta
if tx_total < float(min_total_transactions):
# Too little traffic in window, ratio would be noisy and misleading.
return None, tx_total, rollback_delta
if rollback_delta < float(min_rollbacks):
# Ignore tiny rollback counts even if ratio appears high on low absolute numbers.
return None, tx_total, rollback_delta
return (rollback_delta / tx_total) if tx_total > 0 else None, tx_total, rollback_delta
async def _latest_query_snapshot_max(db: AsyncSession, target_id: int, column_name: str) -> float | None:
latest_ts = await db.scalar(select(func.max(QueryStat.ts)).where(QueryStat.target_id == target_id))
if latest_ts is None:
return None
column = QueryStat.mean_time if column_name == "mean_time" else QueryStat.total_time
value = await db.scalar(
select(func.max(column)).where(QueryStat.target_id == target_id, QueryStat.ts == latest_ts)
)
return float(value) if value is not None else None
async def _build_standard_rules(db: AsyncSession, target: Target) -> tuple[list[_RuleInput], list[AlertStatusItem]]:
rules: list[_RuleInput] = []
forced_items: list[AlertStatusItem] = []
checked_at = datetime.now(timezone.utc)
# 1) Connectivity with RTT threshold.
start = time.perf_counter()
conn: asyncpg.Connection | None = None
try:
conn = await _connect_target(target, timeout_seconds=4)
await conn.fetchval("SELECT 1")
connect_ms = (time.perf_counter() - start) * 1000
rules.append(
_RuleInput(
key="connectivity_rtt_ms",
name="Connectivity Latency",
description="Checks whether the target is reachable and how long the connection handshake takes.",
category="availability",
value=connect_ms,
warning_threshold=1000,
alert_threshold=2500,
comparison="gte",
)
)
except Exception as exc:
forced_items.append(
AlertStatusItem(
alert_key=f"std-connectivity-down-{target.id}",
source="standard",
severity="alert",
category="availability",
name="Target Reachability",
description="Verifies that the monitored database can be reached by the collector.",
target_id=target.id,
target_name=target.name,
value=None,
warning_threshold=None,
alert_threshold=None,
comparison="gte",
message=f"Connection failed: {exc}",
checked_at=checked_at,
)
)
finally:
if conn:
await conn.close()
# 2) Collector freshness based on latest stored metric.
latest_ts = await db.scalar(select(func.max(Metric.ts)).where(Metric.target_id == target.id))
if latest_ts is None:
forced_items.append(
AlertStatusItem(
alert_key=f"std-metric-freshness-{target.id}",
source="standard",
severity="warning",
category="availability",
name="Collector Freshness",
description="Ensures fresh metrics are arriving for the target.",
target_id=target.id,
target_name=target.name,
value=None,
warning_threshold=None,
alert_threshold=None,
comparison="gte",
message="No metrics collected yet for this target.",
checked_at=checked_at,
)
)
else:
age_seconds = max(0.0, (checked_at - latest_ts).total_seconds())
rules.append(
_RuleInput(
key="collector_freshness_seconds",
name="Collector Freshness",
description="Age of the most recent metric sample.",
category="availability",
value=age_seconds,
warning_threshold=float(settings.poll_interval_seconds * 2),
alert_threshold=float(settings.poll_interval_seconds * 4),
)
)
active_connections = await _latest_metric_value(db, target.id, "connections_active")
total_connections = await _latest_metric_value(db, target.id, "connections_total")
cache_hit_ratio = await _latest_metric_value(db, target.id, "cache_hit_ratio")
lock_count = await _latest_metric_value(db, target.id, "locks_total")
checkpoints_req_delta = await _metric_delta(db, target.id, "checkpoints_req", minutes=15)
deadlocks_delta = await _metric_delta(db, target.id, "deadlocks", minutes=60)
slowest_query_mean = await _latest_query_snapshot_max(db, target.id, "mean_time")
slowest_query_total = await _latest_query_snapshot_max(db, target.id, "total_time")
active_ratio = None
if (
active_connections is not None
and total_connections is not None
and total_connections >= settings.alert_active_connection_ratio_min_total_connections
):
active_ratio = active_connections / total_connections
rollback_ratio_window = settings.alert_rollback_ratio_window_minutes
rollback_ratio_val, tx_total_window, rollback_count_window = await _rollback_ratio_recent(
db,
target.id,
minutes=rollback_ratio_window,
min_total_transactions=settings.alert_rollback_ratio_min_total_transactions,
min_rollbacks=settings.alert_rollback_ratio_min_rollbacks,
)
rules.extend(
[
_RuleInput(
key="active_connections_ratio",
name="Active Connection Ratio",
description=(
"Share of active sessions over total sessions. "
f"Only evaluated when total sessions >= {settings.alert_active_connection_ratio_min_total_connections}."
),
category="capacity",
value=active_ratio,
warning_threshold=0.70,
alert_threshold=0.90,
),
_RuleInput(
key="cache_hit_ratio_low",
name="Cache Hit Ratio",
description="Low cache hit ratio means increased disk reads and slower queries.",
category="performance",
value=cache_hit_ratio,
warning_threshold=0.95,
alert_threshold=0.90,
comparison="lte",
),
_RuleInput(
key="locks_total",
name="Lock Pressure",
description="Number of locks currently held on the target.",
category="contention",
value=lock_count,
warning_threshold=50,
alert_threshold=100,
),
_RuleInput(
key="checkpoints_req_15m",
name="Checkpoint Pressure (15m)",
description="Increase of requested checkpoints in the last 15 minutes.",
category="io",
value=checkpoints_req_delta,
warning_threshold=5,
alert_threshold=15,
),
_RuleInput(
key="rollback_ratio",
name="Rollback Ratio",
description=(
f"Fraction of rolled back transactions in the last {rollback_ratio_window} minutes "
f"(evaluated only when at least {settings.alert_rollback_ratio_min_total_transactions} "
f"transactions and {settings.alert_rollback_ratio_min_rollbacks} rollbacks occurred)."
),
category="transactions",
value=rollback_ratio_val,
warning_threshold=0.10,
alert_threshold=0.25,
),
_RuleInput(
key="deadlocks_60m",
name="Deadlocks (60m)",
description="Increase in deadlocks during the last hour.",
category="contention",
value=deadlocks_delta,
warning_threshold=1,
alert_threshold=5,
),
_RuleInput(
key="slowest_query_mean_ms",
name="Slowest Query Mean Time",
description="Highest mean execution time in the latest query snapshot.",
category="query",
value=slowest_query_mean,
warning_threshold=300,
alert_threshold=1000,
),
_RuleInput(
key="slowest_query_total_ms",
name="Slowest Query Total Time",
description="Highest total execution time in the latest query snapshot.",
category="query",
value=slowest_query_total,
warning_threshold=3000,
alert_threshold=10000,
),
]
)
# Expose transaction volume as contextual metric for UI/debugging.
rules.append(
_RuleInput(
key="rollback_tx_volume_15m",
name="Rollback Ratio Evaluation Volume",
description=f"Total transactions in the last {rollback_ratio_window} minutes used for rollback-ratio evaluation.",
category="transactions",
value=tx_total_window,
warning_threshold=None,
alert_threshold=None,
)
)
rules.append(
_RuleInput(
key="rollback_count_window",
name="Rollback Count (Window)",
description=f"Rollback count in the last {rollback_ratio_window} minutes used for rollback-ratio evaluation.",
category="transactions",
value=rollback_count_window,
warning_threshold=None,
alert_threshold=None,
)
)
return rules, forced_items
async def _evaluate_standard_alerts(db: AsyncSession, targets: list[Target]) -> list[AlertStatusItem]:
checked_at = datetime.now(timezone.utc)
items: list[AlertStatusItem] = []
for target in targets:
rules, forced_items = await _build_standard_rules(db, target)
items.extend(forced_items)
for rule in rules:
severity = _severity_from_thresholds(rule.value, rule.comparison, rule.warning_threshold, rule.alert_threshold)
if severity not in {"warning", "alert"}:
continue
items.append(
AlertStatusItem(
alert_key=f"std-{rule.key}-{target.id}",
source="standard",
severity=severity,
category=rule.category,
name=rule.name,
description=rule.description,
target_id=target.id,
target_name=target.name,
value=rule.value,
warning_threshold=rule.warning_threshold,
alert_threshold=rule.alert_threshold,
comparison=rule.comparison,
message=_status_message(rule.value, rule.comparison, rule.warning_threshold, rule.alert_threshold),
checked_at=checked_at,
)
)
return items
async def _evaluate_custom_alerts(db: AsyncSession, targets: list[Target]) -> list[AlertStatusItem]:
checked_at = datetime.now(timezone.utc)
defs = (
await db.scalars(select(AlertDefinition).where(AlertDefinition.enabled.is_(True)).order_by(desc(AlertDefinition.id)))
).all()
target_by_id = {t.id: t for t in targets}
items: list[AlertStatusItem] = []
for definition in defs:
target_candidates = targets if definition.target_id is None else [target_by_id.get(definition.target_id)]
for target in [t for t in target_candidates if t is not None]:
value: float | None = None
severity = "unknown"
message = "No data"
try:
value = await run_scalar_sql_for_target(target, definition.sql_text)
severity = _severity_from_thresholds(
value=value,
comparison=definition.comparison,
warning_threshold=definition.warning_threshold,
alert_threshold=definition.alert_threshold,
)
message = _status_message(value, definition.comparison, definition.warning_threshold, definition.alert_threshold)
except Exception as exc:
severity = "alert"
message = f"Execution failed: {exc}"
if severity not in {"warning", "alert"}:
continue
items.append(
AlertStatusItem(
alert_key=f"custom-{definition.id}-{target.id}",
source="custom",
severity=severity,
category="custom",
name=definition.name,
description=definition.description or "Custom SQL alert",
target_id=target.id,
target_name=target.name,
value=value,
warning_threshold=definition.warning_threshold,
alert_threshold=definition.alert_threshold,
comparison=definition.comparison,
message=message,
checked_at=checked_at,
sql_text=definition.sql_text,
)
)
return items
async def get_alert_status(db: AsyncSession, use_cache: bool = True) -> AlertStatusResponse:
now_seconds = time.time()
cached = _status_cache.get("data")
expires = float(_status_cache.get("expires", 0.0))
if use_cache and cached and expires > now_seconds:
return cached
targets = (await db.scalars(select(Target).order_by(Target.name.asc()))).all()
standard_items = await _evaluate_standard_alerts(db, targets)
custom_items = await _evaluate_custom_alerts(db, targets)
all_items = standard_items + custom_items
warnings = [item for item in all_items if item.severity == "warning"]
alerts = [item for item in all_items if item.severity == "alert"]
warnings.sort(key=lambda i: (i.target_name.lower(), i.name.lower()))
alerts.sort(key=lambda i: (i.target_name.lower(), i.name.lower()))
payload = AlertStatusResponse(
generated_at=datetime.now(timezone.utc),
warnings=warnings,
alerts=alerts,
warning_count=len(warnings),
alert_count=len(alerts),
)
_status_cache["data"] = payload
_status_cache["expires"] = now_seconds + _STATUS_CACHE_TTL_SECONDS
return payload

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
from datetime import datetime, timezone
from random import uniform
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
@@ -13,6 +14,11 @@ import asyncpg
logger = logging.getLogger(__name__)
settings = get_settings()
_failure_state: dict[int, dict[str, object]] = {}
_failure_log_interval_seconds = 300
_backoff_base_seconds = max(3, int(settings.poll_interval_seconds))
_backoff_max_seconds = 300
_backoff_jitter_factor = 0.15
def build_target_dsn(target: Target) -> str:
@@ -41,7 +47,19 @@ async def collect_target(target: Target) -> None:
try:
stat_db = await conn.fetchrow(
"""
SELECT numbackends, xact_commit, xact_rollback, blks_hit, blks_read, tup_returned, tup_fetched
SELECT
numbackends,
xact_commit,
xact_rollback,
deadlocks,
temp_files,
temp_bytes,
blk_read_time,
blk_write_time,
blks_hit,
blks_read,
tup_returned,
tup_fetched
FROM pg_stat_database
WHERE datname = current_database()
"""
@@ -55,18 +73,44 @@ async def collect_target(target: Target) -> None:
WHERE datname = current_database()
"""
)
checkpointer_view_exists = await conn.fetchval("SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL")
bgwriter = None
if checkpointer_view_exists:
try:
bgwriter = await conn.fetchrow(
"""
SELECT
num_timed AS checkpoints_timed,
num_requested AS checkpoints_req,
0::bigint AS buffers_checkpoint,
0::bigint AS buffers_clean,
0::bigint AS maxwritten_clean
FROM pg_stat_checkpointer
"""
)
except Exception:
bgwriter = None
if bgwriter is None:
try:
bgwriter = await conn.fetchrow(
"""
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
FROM pg_stat_bgwriter
"""
)
except Exception:
bgwriter = None
if stat_db is None:
stat_db = {
"numbackends": 0,
"xact_commit": 0,
"xact_rollback": 0,
"deadlocks": 0,
"temp_files": 0,
"temp_bytes": 0,
"blk_read_time": 0,
"blk_write_time": 0,
"blks_hit": 0,
"blks_read": 0,
"tup_returned": 0,
@@ -89,6 +133,7 @@ async def collect_target(target: Target) -> None:
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
query_rows = []
if target.use_pg_stat_statements:
try:
query_rows = await conn.fetch(
"""
@@ -106,6 +151,13 @@ async def collect_target(target: Target) -> None:
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
await _store_metric(db, target.id, "xact_commit", stat_db["xact_commit"], {})
await _store_metric(db, target.id, "xact_rollback", stat_db["xact_rollback"], {})
await _store_metric(db, target.id, "deadlocks", stat_db["deadlocks"], {})
await _store_metric(db, target.id, "temp_files", stat_db["temp_files"], {})
await _store_metric(db, target.id, "temp_bytes", stat_db["temp_bytes"], {})
await _store_metric(db, target.id, "blk_read_time", stat_db["blk_read_time"], {})
await _store_metric(db, target.id, "blk_write_time", stat_db["blk_write_time"], {})
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
await _store_metric(db, target.id, "locks_total", lock_count, {})
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
@@ -133,17 +185,97 @@ async def collect_once() -> None:
async with SessionLocal() as db:
targets = (await db.scalars(select(Target))).all()
active_target_ids = {target.id for target in targets}
stale_target_ids = [target_id for target_id in _failure_state.keys() if target_id not in active_target_ids]
for stale_target_id in stale_target_ids:
_failure_state.pop(stale_target_id, None)
for target in targets:
now = datetime.now(timezone.utc)
state = _failure_state.get(target.id)
if state:
next_attempt_at = state.get("next_attempt_at")
if isinstance(next_attempt_at, datetime) and now < next_attempt_at:
continue
try:
await collect_target(target)
prev = _failure_state.pop(target.id, None)
if prev:
first_failure_at = prev.get("first_failure_at")
downtime_seconds = None
if isinstance(first_failure_at, datetime):
downtime_seconds = max(0, int((now - first_failure_at).total_seconds()))
logger.info(
"collector_target_recovered target=%s after_failures=%s downtime_seconds=%s last_error=%s",
target.id,
prev.get("count", 0),
downtime_seconds,
prev.get("error"),
)
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
logger.exception("collector_error target=%s err=%s", target.id, exc)
current_error = str(exc)
error_class = exc.__class__.__name__
state = _failure_state.get(target.id)
if state is None:
next_delay = min(_backoff_max_seconds, _backoff_base_seconds)
jitter = next_delay * _backoff_jitter_factor
next_delay = max(1, int(next_delay + uniform(-jitter, jitter)))
next_attempt_at = now.timestamp() + next_delay
_failure_state[target.id] = {
"count": 1,
"first_failure_at": now,
"last_log_at": now,
"error": current_error,
"next_attempt_at": datetime.fromtimestamp(next_attempt_at, tz=timezone.utc),
}
logger.warning(
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s retry_in_seconds=%s",
target.id,
error_class,
current_error,
1,
next_delay,
)
continue
count = int(state.get("count", 0)) + 1
raw_backoff = min(_backoff_max_seconds, _backoff_base_seconds * (2 ** min(count - 1, 10)))
jitter = raw_backoff * _backoff_jitter_factor
next_delay = max(1, int(raw_backoff + uniform(-jitter, jitter)))
state["next_attempt_at"] = datetime.fromtimestamp(now.timestamp() + next_delay, tz=timezone.utc)
last_log_at = state.get("last_log_at")
last_logged_error = str(state.get("error", ""))
should_log = False
if current_error != last_logged_error:
should_log = True
elif isinstance(last_log_at, datetime):
should_log = (now - last_log_at).total_seconds() >= _failure_log_interval_seconds
else:
should_log = True
state["count"] = count
if should_log:
state["last_log_at"] = now
state["error"] = current_error
logger.warning(
"collector_target_unreachable target=%s error_class=%s err=%s consecutive_failures=%s retry_in_seconds=%s",
target.id,
error_class,
current_error,
count,
next_delay,
)
async def collector_loop(stop_event: asyncio.Event) -> None:
while not stop_event.is_set():
cycle_started = asyncio.get_running_loop().time()
await collect_once()
elapsed = asyncio.get_running_loop().time() - cycle_started
sleep_for = max(0.0, settings.poll_interval_seconds - elapsed)
try:
await asyncio.wait_for(stop_event.wait(), timeout=settings.poll_interval_seconds)
await asyncio.wait_for(stop_event.wait(), timeout=sleep_for)
except asyncio.TimeoutError:
pass

View File

@@ -17,10 +17,10 @@ class DiskSpaceProvider:
class NullDiskSpaceProvider(DiskSpaceProvider):
async def get_free_bytes(self, target_host: str) -> DiskSpaceProbeResult:
return DiskSpaceProbeResult(
source="none",
source="agentless",
status="unavailable",
free_bytes=None,
message=f"No infra probe configured for host {target_host}. Add SSH/Agent provider later.",
message=f"Agentless mode: host-level free disk is not available for {target_host}.",
)

View File

@@ -106,6 +106,25 @@ async def collect_overview(
errors,
"pg_stat_database_perf",
)
checkpointer_view_exists = await _safe_fetchval(
conn,
"SELECT to_regclass('pg_catalog.pg_stat_checkpointer') IS NOT NULL",
errors,
"checkpointer_view_exists",
)
if checkpointer_view_exists:
bgwriter = await _safe_fetchrow(
conn,
"""
SELECT
num_timed AS checkpoints_timed,
num_requested AS checkpoints_req
FROM pg_stat_checkpointer
""",
errors,
"pg_stat_checkpointer",
)
else:
bgwriter = await _safe_fetchrow(
conn,
"SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter",

View 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)

View File

@@ -1,4 +1,5 @@
fastapi==0.116.1
fastapi==0.129.0
starlette==0.52.1
uvicorn[standard]==0.35.0
gunicorn==23.0.0
sqlalchemy[asyncio]==2.0.44
@@ -7,7 +8,7 @@ alembic==1.16.5
pydantic==2.11.7
pydantic-settings==2.11.0
email-validator==2.2.0
python-jose[cryptography]==3.5.0
PyJWT==2.11.0
passlib[argon2]==1.7.4
cryptography==45.0.7
python-multipart==0.0.20
cryptography==46.0.5
python-multipart==0.0.22

View 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

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Gate pip-audit results with an auditable allowlist policy.
Policy:
- Block unresolved HIGH/CRITICAL vulnerabilities.
- If severity is missing, treat as HIGH by default.
- Allow temporary exceptions via allowlist with expiry metadata.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from pathlib import Path
SEVERITY_ORDER = {"unknown": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
BLOCKING_SEVERITIES = {"high", "critical"}
def _parse_date(s: str) -> dt.date:
return dt.date.fromisoformat(s)
def _normalize_severity(value: object) -> str:
"""Normalize various pip-audit/osv-style severity payloads."""
if isinstance(value, str):
v = value.strip().lower()
if v in SEVERITY_ORDER:
return v
try:
# CVSS numeric string fallback
score = float(v)
if score >= 9.0:
return "critical"
if score >= 7.0:
return "high"
if score >= 4.0:
return "medium"
return "low"
except ValueError:
return "unknown"
if isinstance(value, (int, float)):
score = float(value)
if score >= 9.0:
return "critical"
if score >= 7.0:
return "high"
if score >= 4.0:
return "medium"
return "low"
if isinstance(value, list):
# OSV sometimes returns a list of dicts. Pick the max-known severity.
best = "unknown"
for item in value:
if isinstance(item, dict):
sev = _normalize_severity(item.get("severity"))
if SEVERITY_ORDER.get(sev, 0) > SEVERITY_ORDER.get(best, 0):
best = sev
return best
if isinstance(value, dict):
return _normalize_severity(value.get("severity"))
return "unknown"
def _load_allowlist(path: Path) -> tuple[list[dict], list[str]]:
if not path.exists():
return [], []
data = json.loads(path.read_text(encoding="utf-8"))
entries = data.get("entries", [])
today = dt.date.today()
active: list[dict] = []
errors: list[str] = []
required = {"id", "reason", "approved_by", "issue", "expires_on"}
for idx, entry in enumerate(entries, start=1):
missing = required - set(entry.keys())
if missing:
errors.append(f"allowlist entry #{idx} missing keys: {', '.join(sorted(missing))}")
continue
try:
expires = _parse_date(str(entry["expires_on"]))
except ValueError:
errors.append(f"allowlist entry #{idx} has invalid expires_on: {entry['expires_on']}")
continue
if expires < today:
errors.append(
f"allowlist entry #{idx} ({entry['id']}) expired on {entry['expires_on']}"
)
continue
active.append(entry)
return active, errors
def _iter_findings(report: object):
# pip-audit JSON can be list[dep] or dict with dependencies.
deps = report if isinstance(report, list) else report.get("dependencies", [])
for dep in deps:
package = dep.get("name", "unknown")
version = dep.get("version", "unknown")
for vuln in dep.get("vulns", []):
vuln_id = vuln.get("id", "unknown")
aliases = vuln.get("aliases", []) or []
severity = _normalize_severity(vuln.get("severity"))
if severity == "unknown":
severity = "high" # conservative default for policy safety
yield {
"package": package,
"version": version,
"id": vuln_id,
"aliases": aliases,
"severity": severity,
"fix_versions": vuln.get("fix_versions", []),
}
def _is_allowlisted(finding: dict, allowlist: list[dict]) -> bool:
ids = {finding["id"], *finding["aliases"]}
pkg = finding["package"]
for entry in allowlist:
entry_pkg = entry.get("package")
if entry["id"] in ids and (not entry_pkg or entry_pkg == pkg):
return True
return False
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--report", required=True, help="Path to pip-audit JSON report")
parser.add_argument("--allowlist", required=True, help="Path to allowlist JSON")
args = parser.parse_args()
report_path = Path(args.report)
allowlist_path = Path(args.allowlist)
if not report_path.exists():
print(f"[pip-audit-gate] Missing report: {report_path}")
return 1
report = json.loads(report_path.read_text(encoding="utf-8"))
allowlist, allowlist_errors = _load_allowlist(allowlist_path)
if allowlist_errors:
print("[pip-audit-gate] Allowlist validation failed:")
for err in allowlist_errors:
print(f" - {err}")
return 1
unresolved_blocking: list[dict] = []
summary = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0}
ignored = 0
for finding in _iter_findings(report):
sev = finding["severity"]
summary[sev] = summary.get(sev, 0) + 1
if _is_allowlisted(finding, allowlist):
ignored += 1
continue
if sev in BLOCKING_SEVERITIES:
unresolved_blocking.append(finding)
print("[pip-audit-gate] Summary:")
print(
f" CRITICAL={summary['critical']} HIGH={summary['high']} "
f"MEDIUM={summary['medium']} LOW={summary['low']} ALLOWLISTED={ignored}"
)
if unresolved_blocking:
print("[pip-audit-gate] Blocking vulnerabilities found:")
for f in unresolved_blocking:
aliases = ", ".join(f["aliases"]) if f["aliases"] else "-"
fixes = ", ".join(f["fix_versions"]) if f["fix_versions"] else "-"
print(
f" - {f['severity'].upper()} {f['package']}=={f['version']} "
f"id={f['id']} aliases=[{aliases}] fixes=[{fixes}]"
)
return 1
print("[pip-audit-gate] No unresolved HIGH/CRITICAL vulnerabilities.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,153 @@
import asyncio
import os
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi.testclient import TestClient
from app.core.db import SessionLocal
from app.main import app
from app.models.models import Metric
def _admin_credentials() -> tuple[str, str]:
return (
os.getenv("INIT_ADMIN_EMAIL", "admin@example.com"),
os.getenv("INIT_ADMIN_PASSWORD", "ChangeMe123!"),
)
def _auth_headers(access_token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {access_token}"}
async def _insert_metric(target_id: int, metric_name: str, value: float) -> None:
async with SessionLocal() as db:
db.add(
Metric(
target_id=target_id,
ts=datetime.now(timezone.utc),
metric_name=metric_name,
value=value,
labels={},
)
)
await db.commit()
def test_core_api_smoke_suite() -> None:
admin_email, admin_password = _admin_credentials()
unique = uuid4().hex[:8]
target_name = f"smoke-target-{unique}"
user_email = f"smoke-user-{unique}@example.com"
with TestClient(app) as client:
# Auth: login
login_res = client.post(
"/api/v1/auth/login",
json={"email": admin_email, "password": admin_password},
)
assert login_res.status_code == 200, login_res.text
tokens = login_res.json()
assert tokens.get("access_token")
assert tokens.get("refresh_token")
headers = _auth_headers(tokens["access_token"])
# Auth: me
me_res = client.get("/api/v1/me", headers=headers)
assert me_res.status_code == 200, me_res.text
assert me_res.json()["email"] == admin_email
# Targets: create
create_target_res = client.post(
"/api/v1/targets",
headers=headers,
json={
"name": target_name,
"host": "127.0.0.1",
"port": 5432,
"dbname": "postgres",
"username": "postgres",
"password": "postgres",
"sslmode": "disable",
"use_pg_stat_statements": False,
"owner_user_ids": [],
"tags": {"suite": "e2e-smoke"},
},
)
assert create_target_res.status_code == 201, create_target_res.text
target = create_target_res.json()
target_id = target["id"]
# Targets: list/get/update
list_targets_res = client.get("/api/v1/targets", headers=headers)
assert list_targets_res.status_code == 200, list_targets_res.text
assert any(item["id"] == target_id for item in list_targets_res.json())
get_target_res = client.get(f"/api/v1/targets/{target_id}", headers=headers)
assert get_target_res.status_code == 200, get_target_res.text
update_target_res = client.put(
f"/api/v1/targets/{target_id}",
headers=headers,
json={"name": f"{target_name}-updated"},
)
assert update_target_res.status_code == 200, update_target_res.text
assert update_target_res.json()["name"].endswith("-updated")
# Metrics access
asyncio.run(_insert_metric(target_id, "connections_total", 7.0))
now = datetime.now(timezone.utc)
from_ts = (now - timedelta(minutes=5)).isoformat()
to_ts = (now + timedelta(minutes=5)).isoformat()
metrics_res = client.get(
f"/api/v1/targets/{target_id}/metrics",
headers=headers,
params={"metric": "connections_total", "from": from_ts, "to": to_ts},
)
assert metrics_res.status_code == 200, metrics_res.text
assert isinstance(metrics_res.json(), list)
assert len(metrics_res.json()) >= 1
# Alerts status
alerts_status_res = client.get("/api/v1/alerts/status", headers=headers)
assert alerts_status_res.status_code == 200, alerts_status_res.text
payload = alerts_status_res.json()
assert "warnings" in payload
assert "alerts" in payload
# Admin users: list/create/update/delete
users_res = client.get("/api/v1/admin/users", headers=headers)
assert users_res.status_code == 200, users_res.text
assert isinstance(users_res.json(), list)
create_user_res = client.post(
"/api/v1/admin/users",
headers=headers,
json={
"email": user_email,
"first_name": "Smoke",
"last_name": "User",
"password": "SmokePass123!",
"role": "viewer",
},
)
assert create_user_res.status_code == 201, create_user_res.text
created_user_id = create_user_res.json()["id"]
update_user_res = client.put(
f"/api/v1/admin/users/{created_user_id}",
headers=headers,
json={"role": "operator", "first_name": "SmokeUpdated"},
)
assert update_user_res.status_code == 200, update_user_res.text
assert update_user_res.json()["role"] == "operator"
delete_user_res = client.delete(f"/api/v1/admin/users/{created_user_id}", headers=headers)
assert delete_user_res.status_code == 200, delete_user_res.text
assert delete_user_res.json().get("status") == "deleted"
# Cleanup target
delete_target_res = client.delete(f"/api/v1/targets/{target_id}", headers=headers)
assert delete_target_res.status_code == 200, delete_target_res.text
assert delete_target_res.json().get("status") == "deleted"

View File

@@ -18,8 +18,8 @@ services:
retries: 10
backend:
build:
context: ./backend
image: nesterovicit/nexapg-backend:latest
pull_policy: always
container_name: nexapg-backend
restart: unless-stopped
environment:
@@ -47,16 +47,14 @@ services:
- "${BACKEND_PORT}:8000"
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ${VITE_API_URL}
image: nesterovicit/nexapg-frontend:latest
pull_policy: always
container_name: nexapg-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT}:80"
- "${FRONTEND_PORT}:8080"
volumes:
pg_data:

View File

@@ -0,0 +1,78 @@
# Production Proxy Profile (HTTPS)
This profile defines a secure and repeatable NexaPG deployment behind a reverse proxy.
## Included Profile Files
- `ops/profiles/prod/.env.production.example`
- `ops/profiles/prod/nginx/nexapg.conf`
## CORS Recommendations by Environment
| Environment | Recommended `CORS_ORIGINS` | Notes |
|---|---|---|
| `dev` | `*` or local explicit origins | `*` is acceptable only for local/dev usage. |
| `staging` | Exact staging UI origins | Example: `https://staging-monitor.example.com` |
| `prod` | Exact production UI origin(s) only | No wildcard; use comma-separated HTTPS origins if needed. |
Examples:
```env
# dev only
CORS_ORIGINS=*
# staging
CORS_ORIGINS=https://staging-monitor.example.com
# prod
CORS_ORIGINS=https://monitor.example.com
```
## Reverse Proxy Requirements
For stable auth, CORS, and request context handling, forward these headers to backend:
- `Host`
- `X-Real-IP`
- `X-Forwarded-For`
- `X-Forwarded-Proto`
- `X-Forwarded-Host`
- `X-Forwarded-Port`
Also forward API paths:
- `/api/` -> backend service (`:8000`)
## Mixed-Content Prevention
NexaPG frontend is designed to avoid mixed-content in HTTPS mode:
- Build/runtime default API base is relative (`/api/v1`)
- `frontend/src/api.js` upgrades `http` API URL to `https` when page runs on HTTPS
Recommended production setting:
```env
VITE_API_URL=/api/v1
```
## Validation Checklist
1. Open app over HTTPS and verify:
- login request is `https://.../api/v1/auth/login`
- no browser mixed-content errors in console
2. Verify CORS behavior:
- allowed origin works
- unknown origin is blocked
3. Verify backend receives forwarded protocol:
- proxied responses succeed with no redirect/proto issues
## CI Validation
`Proxy Profile Validation` workflow runs static guardrail checks:
- relative `VITE_API_URL` default
- required API proxy path in frontend NGINX config
- required forwarded headers
- HTTPS mixed-content guard in frontend API resolver
- production profile forbids wildcard CORS

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
docs/screenshots/alerts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@@ -0,0 +1,53 @@
# Dependency Security Exception Flow (pip-audit)
This document defines the auditable exception process for Python dependency vulnerabilities.
## Policy
- CI blocks unresolved `HIGH` and `CRITICAL` dependency vulnerabilities.
- If a vulnerability does not provide severity metadata, it is treated as `HIGH` by policy.
- Temporary exceptions are allowed only through `ops/security/pip-audit-allowlist.json`.
## Allowlist Location
- File: `ops/security/pip-audit-allowlist.json`
- Format:
```json
{
"entries": [
{
"id": "CVE-2026-12345",
"package": "example-package",
"reason": "Upstream fix not released yet",
"approved_by": "security-owner",
"issue": "NX-202",
"expires_on": "2026-12-31"
}
]
}
```
## Required Fields
- `id`: Vulnerability ID (`CVE-*`, `GHSA-*`, or advisory ID)
- `reason`: Why exception is necessary
- `approved_by`: Approver identity
- `issue`: Tracking issue/ticket
- `expires_on`: Expiry date in `YYYY-MM-DD`
Optional:
- `package`: Restrict exception to one dependency package
## Rules
- Expired allowlist entries fail CI.
- Missing required fields fail CI.
- Exceptions must be time-limited and linked to a tracking issue.
- Removing an exception is required once an upstream fix is available.
## Auditability
- Every exception change is tracked in Git history and code review.
- CI logs include blocked vulnerabilities and allowlisted findings counts.

View File

@@ -0,0 +1,74 @@
# Secret Management (Production)
This guide defines secure handling for NexaPG secrets in production deployments.
## In Scope Secrets
- `JWT_SECRET_KEY`
- `ENCRYPTION_KEY`
- `DB_PASSWORD`
- SMTP credentials (configured in Admin Settings, encrypted at rest)
## Do / Don't
## Do
- Use an external secret source (Vault, cloud secret manager, orchestrator secrets, or CI/CD secret injection).
- Keep secrets out of Git history and out of image layers.
- Use strong random values:
- JWT secret: at least 32+ bytes random
- Fernet key: generated via `Fernet.generate_key()`
- Restrict access to runtime secrets (least privilege).
- Rotate secrets on schedule and on incident.
- Store production `.env` with strict permissions if file-based injection is used:
- owner-only read/write (e.g., `chmod 600 .env`)
- Audit who can read/update secrets in your deployment platform.
## Don't
- Do **not** hardcode secrets in source code.
- Do **not** commit `.env` with real values.
- Do **not** bake production secrets into Dockerfiles or image build args.
- Do **not** share secrets in tickets, chat logs, or CI console output.
- Do **not** reuse the same secrets between environments.
## Recommended Secret Providers
Pick one of these models:
1. Platform/Cloud secrets
- AWS Secrets Manager
- Azure Key Vault
- Google Secret Manager
2. HashiCorp Vault
3. CI/CD secret injection
- Inject as runtime env vars during deployment
4. Docker/Kubernetes secrets
- Prefer secret mounts or orchestrator-native secret stores
If you use plain `.env` files, treat them as sensitive artifacts and protect at OS and backup level.
## Rotation Basics
Minimum baseline:
1. `JWT_SECRET_KEY`
- Rotate on schedule (e.g., quarterly) and immediately after compromise.
- Expect existing sessions/tokens to become invalid after rotation.
2. `ENCRYPTION_KEY`
- Rotate with planned maintenance.
- Re-encrypt stored encrypted values (target passwords, SMTP password) during key transition.
3. `DB_PASSWORD`
- Rotate service account credentials regularly.
- Apply password changes in DB and deployment config atomically.
4. SMTP credentials
- Use dedicated sender account/app password.
- Rotate regularly and after provider-side security alerts.
## Operational Checklist
- [ ] No production secret in repository files.
- [ ] No production secret in container image metadata or build args.
- [ ] Runtime secret source documented for your environment.
- [ ] Secret rotation owner and schedule defined.
- [ ] Incident runbook includes emergency rotation steps.

View File

@@ -7,8 +7,14 @@ ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.29-alpine
FROM nginx:1-alpine-slim
RUN apk upgrade --no-cache \
&& mkdir -p /var/cache/nginx /var/run /var/log/nginx /tmp/nginx \
&& chown -R nginx:nginx /var/cache/nginx /var/run /var/log/nginx /tmp/nginx \
&& sed -i 's#pid[[:space:]]\+/run/nginx.pid;#pid /tmp/nginx/nginx.pid;#' /etc/nginx/nginx.conf \
&& sed -i 's#pid[[:space:]]\+/var/run/nginx.pid;#pid /tmp/nginx/nginx.pid;#' /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1
USER 101
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD nginx -t || exit 1

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>NexaPG Monitor</title>
</head>
<body>

View File

@@ -1,5 +1,5 @@
server {
listen 80;
listen 8080;
server_name _;
root /usr/share/nginx/html;

2285
frontend/public/favicon.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 172 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -1,12 +1,15 @@
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 { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { TargetsPage } from "./pages/TargetsPage";
import { TargetDetailPage } from "./pages/TargetDetailPage";
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AlertsPage } from "./pages/AlertsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage";
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
import { UserSettingsPage } from "./pages/UserSettingsPage";
function Protected({ children }) {
const { tokens } = useAuth();
@@ -16,40 +19,136 @@ function Protected({ children }) {
}
function Layout({ children }) {
const { me, logout } = useAuth();
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
const navigate = useNavigate();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
const fullName = [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim();
return (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<img src="/nexapg-logo.svg" alt="NexaPG" className="brand-logo" />
<h1>NexaPG</h1>
</div>
<nav className="sidebar-nav">
<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>
</NavLink>
<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>
</NavLink>
<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>
</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" && (
<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>
</NavLink>
</>
)}
</nav>
<div className="profile">
<div>{me?.email}</div>
<div className="role">{me?.role}</div>
<div className="mode-switch-block">
<div className="mode-switch-label">View Mode</div>
<button
className={`mode-toggle ${uiMode === "easy" ? "easy" : "dba"}`}
onClick={() => setUiMode(uiMode === "easy" ? "dba" : "easy")}
type="button"
>
<span className="mode-pill">Easy</span>
<span className="mode-pill">DBA</span>
</button>
<small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small>
</div>
<div className="profile-name">{fullName || me?.email}</div>
{fullName && <div className="profile-email">{me?.email}</div>}
<div className="role profile-role">{me?.role}</div>
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
User Settings
</NavLink>
<button className="logout-btn" onClick={logout}>Logout</button>
</div>
</aside>
<main className="main">{children}</main>
<main className="main">
{children}
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{alertToasts.map((toast) => (
<div key={toast.id} className={`alert-toast ${toast.severity || "warning"}${toast.closing ? " closing" : ""}`}>
<div className="alert-toast-head">
<strong>{toast.severity === "alert" ? "New Alert" : "New Warning"}</strong>
<div className="toast-actions">
<button
type="button"
className="toast-view"
title="Open in Alerts"
onClick={() => {
navigate(`/alerts?open=${encodeURIComponent(toast.alertKey || "")}`);
dismissAlertToast(toast.id);
}}
>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
fill="currentColor"
/>
</svg>
</span>
</button>
<button type="button" className="toast-close" onClick={() => dismissAlertToast(toast.id)}>
x
</button>
</div>
</div>
<div className="alert-toast-title">{toast.title}</div>
<div className="alert-toast-target">{toast.target}</div>
<div className="alert-toast-message">{toast.message}</div>
</div>
))}
</div>
</main>
</div>
);
}
@@ -68,6 +167,9 @@ export function App() {
<Route path="/targets" element={<TargetsPage />} />
<Route path="/targets/:id" element={<TargetDetailPage />} />
<Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/service-info" element={<ServiceInfoPage />} />
<Route path="/user-settings" element={<UserSettingsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
</Routes>
</Layout>

View File

@@ -35,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
}
}
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `HTTP ${res.status}`);
const raw = await res.text();
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = null;
}
const message = parsed?.message || raw || `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.code = parsed?.code || null;
err.details = parsed?.details || null;
err.requestId = parsed?.request_id || res.headers.get("x-request-id") || null;
throw err;
}
if (res.status === 204) return null;
return res.json();

View File

@@ -2,27 +2,87 @@ import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const TEMPLATE_VARIABLES = [
"target_name",
"target_id",
"alert_name",
"severity",
"category",
"description",
"message",
"value",
"warning_threshold",
"alert_threshold",
"checked_at",
"alert_key",
];
export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth();
const emptyCreateForm = { email: "", first_name: "", last_name: "", password: "", role: "viewer" };
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
const [form, setForm] = useState(emptyCreateForm);
const [editingUserId, setEditingUserId] = useState(null);
const [editForm, setEditForm] = useState({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
const [emailSettings, setEmailSettings] = useState({
enabled: false,
smtp_host: "",
smtp_port: 587,
smtp_username: "",
smtp_password: "",
clear_smtp_password: false,
from_name: "",
from_email: "",
use_starttls: true,
use_ssl: false,
warning_subject_template: "",
alert_subject_template: "",
warning_body_template: "",
alert_body_template: "",
});
const [smtpState, setSmtpState] = useState({ has_password: false, updated_at: null });
const [testRecipient, setTestRecipient] = useState("");
const [smtpInfo, setSmtpInfo] = useState("");
const [error, setError] = useState("");
const load = async () => {
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
const [userRows, smtp] = await Promise.all([
apiFetch("/admin/users", {}, tokens, refresh),
apiFetch("/admin/settings/email", {}, tokens, refresh),
]);
setUsers(userRows);
setEmailSettings((prev) => ({
...prev,
enabled: !!smtp.enabled,
smtp_host: smtp.smtp_host || "",
smtp_port: smtp.smtp_port || 587,
smtp_username: smtp.smtp_username || "",
smtp_password: "",
clear_smtp_password: false,
from_name: smtp.from_name || "",
from_email: smtp.from_email || "",
use_starttls: !!smtp.use_starttls,
use_ssl: !!smtp.use_ssl,
warning_subject_template: smtp.warning_subject_template || "",
alert_subject_template: smtp.alert_subject_template || "",
warning_body_template: smtp.warning_body_template || "",
alert_body_template: smtp.alert_body_template || "",
}));
setSmtpState({ has_password: !!smtp.has_password, updated_at: smtp.updated_at });
setTestRecipient(smtp.from_email || "");
};
useEffect(() => {
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
}, [me]);
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
if (me?.role !== "admin") return <div className="card">Admins only.</div>;
const create = async (e) => {
e.preventDefault();
try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" });
setForm(emptyCreateForm);
await load();
} catch (e) {
setError(String(e.message || e));
@@ -38,30 +98,158 @@ export function AdminUsersPage() {
}
};
const startEdit = (user) => {
setEditingUserId(user.id);
setEditForm({
email: user.email || "",
first_name: user.first_name || "",
last_name: user.last_name || "",
password: "",
role: user.role || "viewer",
});
};
const cancelEdit = () => {
setEditingUserId(null);
setEditForm({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
};
const saveEdit = async (userId) => {
try {
const payload = {
email: editForm.email,
first_name: editForm.first_name.trim() || null,
last_name: editForm.last_name.trim() || null,
role: editForm.role,
};
if (editForm.password.trim()) payload.password = editForm.password;
await apiFetch(`/admin/users/${userId}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
cancelEdit();
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const saveSmtp = async (e) => {
e.preventDefault();
setError("");
setSmtpInfo("");
try {
const payload = {
...emailSettings,
smtp_host: emailSettings.smtp_host.trim() || null,
smtp_username: emailSettings.smtp_username.trim() || null,
from_name: emailSettings.from_name.trim() || null,
from_email: emailSettings.from_email.trim() || null,
smtp_password: emailSettings.smtp_password || null,
warning_subject_template: emailSettings.warning_subject_template.trim() || null,
alert_subject_template: emailSettings.alert_subject_template.trim() || null,
warning_body_template: emailSettings.warning_body_template.trim() || null,
alert_body_template: emailSettings.alert_body_template.trim() || null,
};
await apiFetch("/admin/settings/email", { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
setSmtpInfo("SMTP settings saved.");
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const sendTestMail = async () => {
setError("");
setSmtpInfo("");
try {
const recipient = testRecipient.trim();
if (!recipient) {
throw new Error("Please enter a test recipient email.");
}
await apiFetch(
"/admin/settings/email/test",
{ method: "POST", body: JSON.stringify({ recipient }) },
tokens,
refresh
);
setSmtpInfo(`Test email sent to ${recipient}.`);
} catch (e) {
setError(String(e.message || e));
}
};
const protocolMode = emailSettings.use_ssl ? "ssl" : emailSettings.use_starttls ? "starttls" : "plain";
const setProtocolMode = (mode) => {
if (mode === "ssl") {
setEmailSettings({ ...emailSettings, use_ssl: true, use_starttls: false });
return;
}
if (mode === "starttls") {
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: true });
return;
}
setEmailSettings({ ...emailSettings, use_ssl: false, use_starttls: false });
};
return (
<div>
<h2>Admin Users</h2>
<div className="admin-settings-page">
<h2>Admin Settings</h2>
<p className="muted admin-page-subtitle">Manage users and outgoing notifications for this NexaPG instance.</p>
{error && <div className="card error">{error}</div>}
<form className="card grid three" onSubmit={create}>
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<div className="card">
<div className="admin-section-head">
<h3>User Management</h3>
<p className="muted">Create accounts and manage access roles.</p>
</div>
<form className="grid three admin-user-form" onSubmit={create}>
<div className="admin-field">
<label>First Name</label>
<input
value={form.first_name}
placeholder="Jane"
onChange={(e) => setForm({ ...form, first_name: e.target.value })}
/>
</div>
<div className="admin-field">
<label>Last Name</label>
<input
value={form.last_name}
placeholder="Doe"
onChange={(e) => setForm({ ...form, last_name: e.target.value })}
/>
</div>
<div className="admin-field">
<label>Email</label>
<input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="admin-field">
<label>Password</label>
<input
type="password"
value={form.password}
placeholder="passwort"
placeholder="Set initial password"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div className="admin-field">
<label>Role</label>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button>User anlegen</button>
</div>
<div className="form-actions field-full">
<button className="primary-btn" type="submit">Create User</button>
</div>
</form>
<div className="card">
</div>
<div className="card admin-users-table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Action</th>
@@ -69,16 +257,273 @@ export function AdminUsersPage() {
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
<tr key={u.id} className="admin-user-row">
<td className="user-col-id">{u.id}</td>
<td className="user-col-name">
{editingUserId === u.id ? (
<div className="admin-inline-grid two">
<input
value={editForm.first_name}
placeholder="First name"
onChange={(e) => setEditForm({ ...editForm, first_name: e.target.value })}
/>
<input
value={editForm.last_name}
placeholder="Last name"
onChange={(e) => setEditForm({ ...editForm, last_name: e.target.value })}
/>
</div>
) : (
<span className="user-col-name-value">{[u.first_name, u.last_name].filter(Boolean).join(" ") || "-"}</span>
)}
</td>
<td className="user-col-email">
{editingUserId === u.id ? (
<input
value={editForm.email}
placeholder="user@example.com"
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
) : (
u.email
)}
</td>
<td>
{editingUserId === u.id ? (
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
) : (
<span className={`pill role-pill role-${u.role}`}>{u.role}</span>
)}
</td>
<td className="admin-user-actions">
{editingUserId === u.id && (
<input
type="password"
className="admin-inline-password"
value={editForm.password}
placeholder="New password (optional)"
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
/>
)}
{editingUserId === u.id ? (
<>
<button className="table-action-btn primary small-btn" onClick={() => saveEdit(u.id)}>
Save
</button>
<button className="table-action-btn small-btn" onClick={cancelEdit}>
Cancel
</button>
</>
) : (
<button className="table-action-btn edit small-btn" onClick={() => startEdit(u)}>
Edit
</button>
)}
{u.id !== me.id && (
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="12" height="12">
<path
d="M9 3h6l1 2h4v2H4V5h4l1-2zm1 6h2v8h-2V9zm4 0h2v8h-2V9zM7 9h2v8H7V9z"
fill="currentColor"
/>
</svg>
</span>
<span>Delete</span>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<div className="admin-section-head">
<h3>Alert Email Notifications (SMTP)</h3>
<p className="muted">Configure send-only SMTP for warning and alert notifications.</p>
</div>
{smtpInfo && <div className="test-connection-result ok">{smtpInfo}</div>}
<form className="grid two admin-smtp-form" onSubmit={saveSmtp}>
<div className="admin-subcard field-full">
<h4>SMTP Settings</h4>
<div className="grid two">
<label className="toggle-check field-full">
<input
type="checkbox"
checked={emailSettings.enabled}
onChange={(e) => setEmailSettings({ ...emailSettings, enabled: e.target.checked })}
/>
<span className="toggle-ui" />
<span>
<strong>Enable alert emails</strong>
</span>
</label>
<div className="admin-field">
<label>SMTP host</label>
<input
value={emailSettings.smtp_host}
placeholder="smtp.example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })}
/>
</div>
<div className="admin-field">
<label>SMTP port</label>
<input
type="number"
value={emailSettings.smtp_port}
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_port: Number(e.target.value || 587) })}
/>
</div>
<div className="admin-field">
<label>SMTP username</label>
<input
value={emailSettings.smtp_username}
placeholder="alerts@example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_username: e.target.value })}
/>
</div>
<div className="admin-field">
<label>SMTP password</label>
<input
type="password"
value={emailSettings.smtp_password}
placeholder={smtpState.has_password ? "Stored (enter to replace)" : "Set password"}
onChange={(e) => setEmailSettings({ ...emailSettings, smtp_password: e.target.value, clear_smtp_password: false })}
/>
</div>
<div className="admin-field">
<label>From name</label>
<input
value={emailSettings.from_name}
placeholder="NexaPG Alerts"
onChange={(e) => setEmailSettings({ ...emailSettings, from_name: e.target.value })}
/>
</div>
<div className="admin-field">
<label>From email</label>
<input
value={emailSettings.from_email}
placeholder="noreply@example.com"
onChange={(e) => setEmailSettings({ ...emailSettings, from_email: e.target.value })}
/>
</div>
<div className="admin-field field-full">
<label>Transport mode</label>
<div className="smtp-mode-picker" role="radiogroup" aria-label="SMTP transport mode">
<button
type="button"
className={`smtp-mode-btn ${protocolMode === "starttls" ? "active" : ""}`}
aria-pressed={protocolMode === "starttls"}
onClick={() => setProtocolMode("starttls")}
>
STARTTLS
</button>
<button
type="button"
className={`smtp-mode-btn ${protocolMode === "ssl" ? "active" : ""}`}
aria-pressed={protocolMode === "ssl"}
onClick={() => setProtocolMode("ssl")}
>
SSL/TLS (SMTPS)
</button>
<button
type="button"
className={`smtp-mode-btn ${protocolMode === "plain" ? "active" : ""}`}
aria-pressed={protocolMode === "plain"}
onClick={() => setProtocolMode("plain")}
>
No TLS
</button>
</div>
<small className="muted">Select exactly one mode to avoid STARTTLS/SSL conflicts.</small>
</div>
<label className="toggle-check field-full">
<input
type="checkbox"
checked={emailSettings.clear_smtp_password}
onChange={(e) => setEmailSettings({ ...emailSettings, clear_smtp_password: e.target.checked, smtp_password: e.target.checked ? "" : emailSettings.smtp_password })}
/>
<span className="toggle-ui" />
<span>Clear stored SMTP password</span>
</label>
</div>
</div>
<div className="admin-subcard field-full">
<h4>Template Settings</h4>
<p className="muted template-help-text">
If a template field is left empty, NexaPG automatically uses the built-in default template.
</p>
<div className="template-vars-grid">
{TEMPLATE_VARIABLES.map((item) => (
<code key={item} className="template-var-pill">
{"{" + item + "}"}
</code>
))}
</div>
<div className="grid two">
<div className="admin-field field-full">
<label>Warning subject template</label>
<input
value={emailSettings.warning_subject_template}
placeholder="[NexaPG][WARNING] {target_name} - {alert_name}"
onChange={(e) => setEmailSettings({ ...emailSettings, warning_subject_template: e.target.value })}
/>
</div>
<div className="admin-field field-full">
<label>Alert subject template</label>
<input
value={emailSettings.alert_subject_template}
placeholder="[NexaPG][ALERT] {target_name} - {alert_name}"
onChange={(e) => setEmailSettings({ ...emailSettings, alert_subject_template: e.target.value })}
/>
</div>
<div className="admin-field field-full">
<label>Warning body template</label>
<textarea
value={emailSettings.warning_body_template}
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nChecked At: {checked_at}"}
onChange={(e) => setEmailSettings({ ...emailSettings, warning_body_template: e.target.value })}
/>
</div>
<div className="admin-field field-full">
<label>Alert body template</label>
<textarea
value={emailSettings.alert_body_template}
placeholder={"Severity: {severity}\nTarget: {target_name} (id={target_id})\nAlert: {alert_name}\nMessage: {message}\nCurrent Value: {value}\nWarning Threshold: {warning_threshold}\nAlert Threshold: {alert_threshold}\nChecked At: {checked_at}\nAlert Key: {alert_key}"}
onChange={(e) => setEmailSettings({ ...emailSettings, alert_body_template: e.target.value })}
/>
</div>
</div>
</div>
<div className="form-actions field-full">
<input
className="admin-test-recipient"
value={testRecipient}
placeholder="test recipient email"
onChange={(e) => setTestRecipient(e.target.value)}
/>
<button className="secondary-btn" type="button" onClick={sendTestMail}>Send Test Mail</button>
<button className="primary-btn" type="submit">Save SMTP Settings</button>
</div>
<small className="muted field-full">
Last updated: {smtpState.updated_at ? new Date(smtpState.updated_at).toLocaleString() : "not configured yet"}
</small>
</form>
</div>
</div>
);
}

View 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 (&gt;=)</option>
<option value="gt">greater than (&gt;)</option>
<option value="lte">less than or equal (&lt;=)</option>
<option value="lt">less than (&lt;)</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>
);
}

View File

@@ -3,9 +3,20 @@ import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
function getTargetGroupMeta(target) {
const tags = target?.tags || {};
if (tags.monitor_mode !== "all_databases" || !tags.monitor_group_id) return null;
return {
id: tags.monitor_group_id,
name: tags.monitor_group_name || target.name || "All databases",
};
}
export function DashboardPage() {
const { tokens, refresh } = useAuth();
const { tokens, refresh, alertStatus } = useAuth();
const [targets, setTargets] = useState([]);
const [search, setSearch] = useState("");
const [openGroups, setOpenGroups] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -13,8 +24,10 @@ export function DashboardPage() {
let active = true;
(async () => {
try {
const data = await apiFetch("/targets", {}, tokens, refresh);
if (active) setTargets(data);
const targetRows = await apiFetch("/targets", {}, tokens, refresh);
if (active) {
setTargets(targetRows);
}
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
@@ -26,48 +39,172 @@ export function DashboardPage() {
};
}, [tokens, refresh]);
if (loading) return <div className="card">Lade Dashboard...</div>;
if (loading) return <div className="card">Loading dashboard...</div>;
if (error) return <div className="card error">{error}</div>;
const targetSeverities = new Map();
for (const item of alertStatus.warnings || []) {
if (!targetSeverities.has(item.target_id)) targetSeverities.set(item.target_id, "warning");
}
for (const item of alertStatus.alerts || []) {
targetSeverities.set(item.target_id, "alert");
}
const affectedTargetCount = targetSeverities.size;
const okCount = Math.max(0, targets.length - affectedTargetCount);
const filteredTargets = targets.filter((t) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
<div>
(t.name || "").toLowerCase().includes(q) ||
(t.host || "").toLowerCase().includes(q) ||
(t.dbname || "").toLowerCase().includes(q)
);
});
const groupedRows = [];
const groupedMap = new Map();
for (const t of filteredTargets) {
const meta = getTargetGroupMeta(t);
if (!meta) {
groupedRows.push({ type: "single", target: t });
continue;
}
if (!groupedMap.has(meta.id)) {
const groupRow = { type: "group", groupId: meta.id, groupName: meta.name, targets: [] };
groupedMap.set(meta.id, groupRow);
groupedRows.push(groupRow);
}
groupedMap.get(meta.id).targets.push(t);
}
return (
<div className="dashboard-page">
<h2>Dashboard Overview</h2>
<div className="grid three">
<div className="card stat">
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
<div className="dashboard-kpis-grid">
<div className="card stat kpi-card">
<div className="kpi-orb blue" />
<strong>{targets.length}</strong>
<span>Targets</span>
<span className="kpi-label">Total Targets</span>
</div>
<div className="card stat">
<strong>{targets.length}</strong>
<span>Status OK (placeholder)</span>
<div className="card stat kpi-card ok">
<div className="kpi-orb green" />
<strong>{okCount}</strong>
<span className="kpi-label">Status OK</span>
</div>
<div className="card stat">
<strong>0</strong>
<span>Alerts (placeholder)</span>
<div className="card stat kpi-card warning">
<div className="kpi-orb amber" />
<strong>{alertStatus.warning_count || 0}</strong>
<span className="kpi-label">Warnings</span>
</div>
<div className="card stat kpi-card alert">
<div className="kpi-orb red" />
<strong>{alertStatus.alert_count || 0}</strong>
<span className="kpi-label">Alerts</span>
</div>
</div>
<div className="card">
<div className="card dashboard-targets-card">
<div className="dashboard-targets-head">
<div>
<h3>Targets</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
</tr>
))}
</tbody>
</table>
<span>{filteredTargets.length} shown of {targets.length} registered</span>
</div>
<div className="dashboard-target-search">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, host, or database..."
/>
</div>
</div>
<div className="dashboard-target-list">
{groupedRows.map((row) => {
if (row.type === "single") {
const t = row.target;
const severity = targetSeverities.get(t.id) || "ok";
return (
<article className="dashboard-target-card" key={`single-${t.id}`}>
<div className="target-main">
<div className="target-title-row">
<h4>{t.name}</h4>
<span className={`status-chip ${severity}`}>
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
</span>
</div>
<p><strong>Host:</strong> {t.host}:{t.port}</p>
<p><strong>DB:</strong> {t.dbname}</p>
</div>
<div className="target-actions">
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
</svg>
</span>
Details
</Link>
</div>
</article>
);
}
const highestSeverity = row.targets.some((t) => targetSeverities.get(t.id) === "alert")
? "alert"
: row.targets.some((t) => targetSeverities.get(t.id) === "warning")
? "warning"
: "ok";
const first = row.targets[0];
const isOpen = !!openGroups[row.groupId];
return (
<article className="dashboard-target-card dashboard-target-group" key={`group-${row.groupId}`}>
<div className="target-main">
<div className="target-title-row">
<h4>{row.groupName}</h4>
<span className={`status-chip ${highestSeverity}`}>
{highestSeverity === "alert" ? "Alert" : highestSeverity === "warning" ? "Warning" : "OK"}
</span>
</div>
<p><strong>Host:</strong> {first.host}:{first.port}</p>
<p><strong>DB:</strong> All databases ({row.targets.length})</p>
</div>
<div className="target-actions">
<button
type="button"
className="table-action-btn edit"
onClick={() => setOpenGroups((prev) => ({ ...prev, [row.groupId]: !prev[row.groupId] }))}
>
{isOpen ? "Hide DBs" : "Show DBs"}
</button>
</div>
{isOpen && (
<div className="dashboard-group-list">
{row.targets.map((t) => {
const severity = targetSeverities.get(t.id) || "ok";
return (
<div key={`child-${t.id}`} className="dashboard-group-item">
<div>
<strong>{t.dbname}</strong>
<span className={`status-chip ${severity}`}>
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
</span>
</div>
<Link className="table-action-btn details small-btn" to={`/targets/${t.id}`}>
Details
</Link>
</div>
);
})}
</div>
)}
</article>
);
})}
{filteredTargets.length === 0 && (
<div className="dashboard-empty">No targets match your search.</div>
)}
</div>
</div>
</div>
);

View File

@@ -18,7 +18,7 @@ export function LoginPage() {
await login(email, password);
navigate("/");
} catch {
setError("Login fehlgeschlagen");
setError("Login failed");
} finally {
setLoading(false);
}
@@ -27,13 +27,16 @@ export function LoginPage() {
return (
<div className="login-wrap">
<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>
<h2>Willkommen zurück</h2>
<p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
<h2>Welcome back</h2>
<p className="login-subtitle">Sign in to access monitoring and query insights.</p>
<div className="input-shell">
<input
type="email"
placeholder="E-Mail"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
@@ -49,7 +52,7 @@ export function LoginPage() {
/>
</div>
{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>
</div>
);

View File

@@ -1,46 +1,165 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const PAGE_SIZE = 10;
function scoreQuery(row) {
const mean = Number(row.mean_time || 0);
const calls = Number(row.calls || 0);
const total = Number(row.total_time || 0);
const rows = Number(row.rows || 0);
return mean * 1.4 + total * 0.9 + calls * 0.2 + Math.min(rows / 50, 25);
}
function classifyQuery(row) {
if ((row.mean_time || 0) > 100) return { label: "Very Slow", kind: "danger" };
if ((row.total_time || 0) > 250) return { label: "Heavy", kind: "warn" };
if ((row.calls || 0) > 500) return { label: "Frequent", kind: "info" };
return { label: "Normal", kind: "ok" };
}
function compactSql(sql) {
if (!sql) return "-";
return sql.replace(/\s+/g, " ").trim();
}
function buildQueryTips(row) {
if (!row) return [];
const tips = [];
const sql = (row.query_text || "").toLowerCase();
if ((row.mean_time || 0) > 100) {
tips.push("High latency per call: inspect execution plan with EXPLAIN (ANALYZE, BUFFERS).");
}
if ((row.total_time || 0) > 500) {
tips.push("High cumulative runtime: optimize this query first for fastest overall impact.");
}
if ((row.calls || 0) > 500) {
tips.push("Very frequent query: consider caching or reducing call frequency in application code.");
}
if ((row.rows || 0) > 100000) {
tips.push("Large row output: return fewer columns/rows or add pagination at query/API layer.");
}
if (/select\s+\*/.test(sql)) {
tips.push("Avoid SELECT *: request only required columns to reduce IO and transfer cost.");
}
if (/order\s+by/.test(sql) && !/limit\s+\d+/.test(sql)) {
tips.push("ORDER BY without LIMIT can be expensive: add LIMIT where possible.");
}
if (/where/.test(sql) && / from /.test(sql)) {
tips.push("Filter query detected: verify indexes exist on WHERE / JOIN columns.");
}
if (/like\s+'%/.test(sql)) {
tips.push("Leading wildcard LIKE can bypass indexes: consider trigram index (pg_trgm).");
}
if (tips.length === 0) {
tips.push("No obvious anti-pattern detected. Validate with EXPLAIN and index usage statistics.");
}
return tips.slice(0, 5);
}
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const refreshRef = useRef(refresh);
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
const [selectedQuery, setSelectedQuery] = useState(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
(async () => {
try {
const t = await apiFetch("/targets", {}, tokens, refresh);
setTargets(t);
if (t.length > 0) setTargetId(String(t[0].id));
const supported = t.filter((item) => item.use_pg_stat_statements !== false);
setTargets(supported);
if (supported.length > 0) setTargetId(String(supported[0].id));
else setTargetId("");
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!targetId) return;
let active = true;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refreshRef.current);
if (!active) return;
setRows(data);
setSelectedQuery((prev) => {
if (!prev) return data[0] || null;
const keep = data.find((row) => row.queryid === prev.queryid);
return keep || data[0] || null;
});
setPage((prev) => (prev === 1 ? prev : 1));
} catch (e) {
setError(String(e.message || e));
if (active) setError(String(e.message || e));
}
})();
}, [targetId, tokens, refresh]);
return () => {
active = false;
};
}, [targetId, tokens?.accessToken, tokens?.refreshToken]);
const dedupedByQueryId = [...rows].reduce((acc, row) => {
if (!row?.queryid) return acc;
if (!acc[row.queryid]) acc[row.queryid] = row;
return acc;
}, {});
const uniqueRows = Object.values(dedupedByQueryId);
const sorted = [...uniqueRows].sort((a, b) => (b.total_time || 0) - (a.total_time || 0));
const byMean = [...uniqueRows].sort((a, b) => (b.mean_time || 0) - (a.mean_time || 0));
const byCalls = [...uniqueRows].sort((a, b) => (b.calls || 0) - (a.calls || 0));
const byRows = [...uniqueRows].sort((a, b) => (b.rows || 0) - (a.rows || 0));
const byPriority = [...uniqueRows].sort((a, b) => scoreQuery(b) - scoreQuery(a));
const filtered = byPriority.filter((r) => {
if (!search.trim()) return true;
return compactSql(r.query_text).toLowerCase().includes(search.toLowerCase());
});
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const selectedTips = buildQueryTips(selectedQuery);
const categories = [
{ key: "priority", title: "Optimization Priority", row: byPriority[0], subtitle: "Best first candidate to optimize" },
{ key: "total", title: "Longest Total Time", row: sorted[0], subtitle: "Biggest cumulative runtime impact" },
{ key: "mean", title: "Highest Mean Time", row: byMean[0], subtitle: "Slowest single-call latency" },
{ key: "calls", title: "Most Frequent", row: byCalls[0], subtitle: "Executed very often" },
{ key: "rows", title: "Most Rows Returned", row: byRows[0], subtitle: "Potentially heavy scans" },
];
return (
<div>
<div className="query-insights-page">
<h2>Query Insights</h2>
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
{error && <div className="card error">{error}</div>}
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
{targets.length === 0 && !loading && (
<div className="card">
<label>Target </label>
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
No targets with enabled <code>pg_stat_statements</code> are available.
Enable it in <strong>Targets Management</strong> for a target to use Query Insights.
</div>
)}
{error && <div className="card error">{error}</div>}
<div className="card query-toolbar">
<div className="field">
<label>Target</label>
<select value={targetId} onChange={(e) => setTargetId(e.target.value)} disabled={!targets.length}>
{targets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
@@ -48,32 +167,141 @@ export function QueryInsightsPage() {
))}
</select>
</div>
<div className="card">
<div className="field">
<label>Search Query Text</label>
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="e.g. select * from users"
/>
</div>
</div>
{loading ? (
<div className="card">Loading query insights...</div>
) : (
<>
<div className="grid three">
{categories.map((item) => {
const r = item.row;
const state = r ? classifyQuery(r) : null;
return (
<div
className="card query-category"
key={item.key}
onClick={() => r && setSelectedQuery(r)}
role="button"
tabIndex={0}
>
<h4>{item.title}</h4>
<small>{item.subtitle}</small>
{r ? (
<>
<div className={`query-state ${state.kind}`}>{state.label}</div>
<div className="query-kpi">
<span>Total</span>
<strong>{Number(r.total_time || 0).toFixed(2)} ms</strong>
</div>
<div className="query-kpi">
<span>Mean</span>
<strong>{Number(r.mean_time || 0).toFixed(2)} ms</strong>
</div>
<div className="query-kpi">
<span>Calls</span>
<strong>{r.calls}</strong>
</div>
</>
) : (
<p>No data</p>
)}
</div>
);
})}
</div>
<div className="grid two">
<div className="card query-list">
<h3>Ranked Queries</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>Priority</th>
<th>Calls</th>
<th>Total ms</th>
<th>Mean ms</th>
<th>Rows</th>
<th>Query</th>
<th>Query Preview</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{new Date(r.ts).toLocaleString()}</td>
{paged.map((r, i) => {
const state = classifyQuery(r);
return (
<tr key={`${r.queryid}-${i}-${safePage}`} className={selectedQuery?.queryid === r.queryid ? "active-row" : ""}>
<td><span className={`query-state ${state.kind}`}>{state.label}</span></td>
<td>{r.calls}</td>
<td>{r.total_time.toFixed(2)}</td>
<td>{r.mean_time.toFixed(2)}</td>
<td>{Number(r.total_time || 0).toFixed(2)}</td>
<td>{Number(r.mean_time || 0).toFixed(2)}</td>
<td>{r.rows}</td>
<td className="query">{r.query_text || "-"}</td>
<td className="query">
<button className="table-link" onClick={() => setSelectedQuery(r)}>
{compactSql(r.query_text)}
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
<div className="pagination">
<span className="pagination-info">
Showing {paged.length} of {filtered.length} queries
</span>
<div className="pagination-actions">
<button type="button" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Prev
</button>
<span>Page {safePage} / {totalPages}</span>
<button type="button" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
Next
</button>
</div>
</div>
</div>
<div className="card query-detail">
<h3>Selected Query</h3>
{selectedQuery ? (
<>
<div className="overview-metrics">
<div><span>Calls</span><strong>{selectedQuery.calls}</strong></div>
<div><span>Total Time</span><strong>{Number(selectedQuery.total_time || 0).toFixed(2)} ms</strong></div>
<div><span>Mean Time</span><strong>{Number(selectedQuery.mean_time || 0).toFixed(2)} ms</strong></div>
<div><span>Rows</span><strong>{selectedQuery.rows}</strong></div>
</div>
<div className="sql-block">
<code>{selectedQuery.query_text || "-- no query text available --"}</code>
</div>
<div className="query-tips">
<h4>Optimization Suggestions</h4>
<ul>
{selectedTips.map((tip, idx) => <li key={idx}>{tip}</li>)}
</ul>
</div>
<p className="query-hint">
Tip: focus first on queries with high <strong>Total Time</strong> (overall impact) and high <strong>Mean Time</strong> (latency hotspots).
</p>
</>
) : (
<p>No query selected.</p>
)}
</div>
</div>
</>
)}
</div>
);
}

View 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>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { apiFetch } from "../api";
import { useAuth } from "../state";
@@ -41,6 +41,19 @@ function formatNumber(value, digits = 2) {
return Number(value).toFixed(digits);
}
function formatHostMetricUnavailable() {
return "N/A (agentless)";
}
function formatDiskSpaceAgentless(diskSpace) {
if (!diskSpace) return formatHostMetricUnavailable();
if (diskSpace.free_bytes !== null && diskSpace.free_bytes !== undefined) {
return formatBytes(diskSpace.free_bytes);
}
if (diskSpace.status === "unavailable") return formatHostMetricUnavailable();
return "-";
}
function MetricsTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
const row = payload[0]?.payload || {};
@@ -54,6 +67,19 @@ function MetricsTooltip({ active, payload, label }) {
);
}
function didMetricSeriesChange(prev = [], next = []) {
if (!Array.isArray(prev) || !Array.isArray(next)) return true;
if (prev.length !== next.length) return true;
if (prev.length === 0 && next.length === 0) return false;
const prevLast = prev[prev.length - 1];
const nextLast = next[next.length - 1];
return prevLast?.ts !== nextLast?.ts || Number(prevLast?.value) !== Number(nextLast?.value);
}
function isTargetUnreachableError(err) {
return err?.code === "target_unreachable" || err?.status === 503;
}
async function loadMetric(targetId, metric, range, tokens, refresh) {
const { from, to } = toQueryRange(range);
return apiFetch(
@@ -66,47 +92,125 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
export function TargetDetailPage() {
const { id } = useParams();
const { tokens, refresh } = useAuth();
const navigate = useNavigate();
const { tokens, refresh, uiMode } = useAuth();
const [range, setRange] = useState("1h");
const [liveMode, setLiveMode] = useState(false);
const [series, setSeries] = useState({});
const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]);
const [overview, setOverview] = useState(null);
const [targetMeta, setTargetMeta] = useState(null);
const [owners, setOwners] = useState([]);
const [groupTargets, setGroupTargets] = useState([]);
const [offlineState, setOfflineState] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
let active = true;
(async () => {
const loadAll = async () => {
if (!series.connections?.length) {
setLoading(true);
}
try {
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refresh),
loadMetric(id, "xacts_total", range, tokens, refresh),
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
apiFetch(`/targets/${id}`, {}, tokens, refresh),
const [connections, xacts, cache, targetInfo, ownerRows, allTargets] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
apiFetch("/targets", {}, tokens, refreshRef.current),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
setTargetMeta(targetInfo);
setOwners(ownerRows);
const groupId = targetInfo?.tags?.monitor_group_id;
if (groupId) {
const sameGroup = allTargets
.filter((item) => item?.tags?.monitor_group_id === groupId)
.sort((a, b) => (a.dbname || "").localeCompare(b.dbname || ""));
setGroupTargets(sameGroup);
} else {
setGroupTargets([]);
}
try {
const [locksTable, activityTable, overviewData] = await Promise.all([
apiFetch(`/targets/${id}/locks`, {}, tokens, refreshRef.current),
apiFetch(`/targets/${id}/activity`, {}, tokens, refreshRef.current),
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
]);
if (!active) return;
setLocks(locksTable);
setActivity(activityTable);
setOverview(overviewData);
setTargetMeta(targetInfo);
setOfflineState(null);
} catch (liveErr) {
if (!active) return;
if (isTargetUnreachableError(liveErr)) {
setLocks([]);
setActivity([]);
setOverview(null);
setOfflineState({
message:
"Target is currently unreachable. Check host/port, network route, SSL mode, and database availability.",
host: liveErr?.details?.host || targetInfo?.host || "-",
port: liveErr?.details?.port || targetInfo?.port || "-",
requestId: liveErr?.requestId || null,
});
} else {
throw liveErr;
}
}
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
};
loadAll();
return () => {
active = false;
};
}, [id, range, tokens, refresh]);
}, [id, range, tokens?.accessToken, tokens?.refreshToken]);
useEffect(() => {
if (!liveMode) return;
let active = true;
const intervalId = setInterval(async () => {
try {
const [connections, xacts, cache] = await Promise.all([
loadMetric(id, "connections_total", "15m", tokens, refreshRef.current),
loadMetric(id, "xacts_total", "15m", tokens, refreshRef.current),
loadMetric(id, "cache_hit_ratio", "15m", tokens, refreshRef.current),
]);
if (!active) return;
const nextSeries = { connections, xacts, cache };
setSeries((prev) => {
const changed =
didMetricSeriesChange(prev.connections, nextSeries.connections) ||
didMetricSeriesChange(prev.xacts, nextSeries.xacts) ||
didMetricSeriesChange(prev.cache, nextSeries.cache);
return changed ? nextSeries : prev;
});
} catch {
// Keep previous chart values if a live tick fails.
}
}, 3000);
return () => {
active = false;
clearInterval(intervalId);
};
}, [liveMode, id, tokens?.accessToken, tokens?.refreshToken]);
const chartData = useMemo(
() => {
@@ -135,7 +239,39 @@ export function TargetDetailPage() {
[series]
);
if (loading) return <div className="card">Lade Target Detail...</div>;
const easySummary = useMemo(() => {
if (!overview) return null;
const latest = chartData[chartData.length - 1];
const issues = [];
const warnings = [];
if (overview.partial_failures?.length > 0) warnings.push("Some advanced metrics are currently unavailable.");
if ((overview.performance?.deadlocks || 0) > 0) issues.push("Deadlocks were detected.");
if ((overview.replication?.mode === "standby") && (overview.replication?.replay_lag_seconds || 0) > 5) {
issues.push("Replication lag is above 5 seconds.");
}
if ((latest?.cache || 0) > 0 && latest.cache < 90) warnings.push("Cache hit ratio is below 90%.");
if ((latest?.connections || 0) > 120) warnings.push("Connection count is relatively high.");
if ((locks?.length || 0) > 150) warnings.push("High number of active locks.");
const health = issues.length > 0 ? "problem" : warnings.length > 0 ? "warning" : "ok";
const message =
health === "ok"
? "Everything looks healthy. No major risks detected right now."
: health === "warning"
? "System is operational, but there are signals you should watch."
: "Attention required. Critical signals need investigation.";
return {
health,
message,
issues,
warnings,
latest,
};
}, [overview, chartData, locks]);
if (loading) return <div className="card">Loading target detail...</div>;
if (error) return <div className="card error">{error}</div>;
const role = overview?.instance?.role || "-";
@@ -148,16 +284,129 @@ export function TargetDetailPage() {
Target Detail {targetMeta?.name || `#${id}`}
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
</h2>
{overview && (
{groupTargets.length > 1 && (
<div className="field target-db-switcher">
<label>Database in this target group</label>
<select
value={String(id)}
onChange={(e) => {
const targetId = e.target.value;
if (targetId && String(targetId) !== String(id)) {
navigate(`/targets/${targetId}`);
}
}}
>
{groupTargets.map((item) => (
<option key={item.id} value={item.id}>
{item.dbname} ({item.name})
</option>
))}
</select>
</div>
)}
<div className="owner-row">
<span className="muted">Responsible users:</span>
{owners.length > 0 ? owners.map((item) => <span key={item.user_id} className="owner-pill">{item.email}</span>) : <span className="muted">none assigned</span>}
</div>
{offlineState && (
<div className="card target-offline-card">
<h3>Target Offline</h3>
<p>{offlineState.message}</p>
<div className="target-offline-meta">
<span><strong>Host:</strong> {offlineState.host}</span>
<span><strong>Port:</strong> {offlineState.port}</span>
{offlineState.requestId ? <span><strong>Request ID:</strong> {offlineState.requestId}</span> : null}
</div>
</div>
)}
{uiMode === "easy" && overview && easySummary && (
<>
<div className={`card easy-status ${easySummary.health}`}>
<h3>System Health</h3>
<p>{easySummary.message}</p>
<div className="easy-badge-row">
<span className={`easy-badge ${easySummary.health}`}>
{easySummary.health === "ok" ? "OK" : easySummary.health === "warning" ? "Warning" : "Problem"}
</span>
</div>
</div>
<div className="grid three">
<div className="card stat">
<strong>{formatNumber(easySummary.latest?.connections, 0)}</strong>
<span>Current Connections</span>
</div>
<div className="card stat">
<strong>{formatNumber(easySummary.latest?.tps, 2)}</strong>
<span>Transactions/sec (approx)</span>
</div>
<div className="card stat">
<strong>{formatNumber(easySummary.latest?.cache, 2)}%</strong>
<span>Cache Hit Ratio</span>
</div>
</div>
<div className="card">
<h3>Quick Explanation</h3>
{easySummary.issues.length === 0 && easySummary.warnings.length === 0 && (
<p>No immediate problems were detected. Keep monitoring over time.</p>
)}
{easySummary.issues.length > 0 && (
<>
<strong>Problems</strong>
<ul className="easy-list">
{easySummary.issues.map((item, idx) => <li key={`i-${idx}`}>{item}</li>)}
</ul>
</>
)}
{easySummary.warnings.length > 0 && (
<>
<strong>Things to watch</strong>
<ul className="easy-list">
{easySummary.warnings.map((item, idx) => <li key={`w-${idx}`}>{item}</li>)}
</ul>
</>
)}
</div>
<div className="grid two">
<div className="card">
<h3>Instance Summary</h3>
<div className="overview-metrics">
<div><span>Role</span><strong>{overview.instance.role}</strong></div>
<div><span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong></div>
<div><span>Target Port</span><strong>{targetMeta?.port ?? "-"}</strong></div>
<div><span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong></div>
<div><span>Replication Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
</div>
</div>
<div className="card">
<h3>Activity Snapshot</h3>
<div className="overview-metrics">
<div><span>Running Sessions</span><strong>{activity.filter((a) => a.state === "active").length}</strong></div>
<div><span>Total Sessions</span><strong>{activity.length}</strong></div>
<div><span>Current Locks</span><strong>{locks.length}</strong></div>
<div><span>Deadlocks</span><strong>{overview.performance.deadlocks ?? 0}</strong></div>
</div>
</div>
</div>
</>
)}
{uiMode === "dba" && overview && (
<div className="card">
<h3>Database Overview</h3>
<p className="muted" style={{ marginTop: 2 }}>
Agentless mode: host-level CPU, RAM, and free-disk metrics are not available.
</p>
<div className="grid three overview-kv">
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
<div>
<span>Role</span>
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
</div>
<div title="Zeit seit Start des Postgres-Prozesses">
<div title="Time since PostgreSQL postmaster start">
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
</div>
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
@@ -165,16 +414,16 @@ export function TargetDetailPage() {
<span>Target Port</span>
<strong>{targetMeta?.port ?? "-"}</strong>
</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>
</div>
<div title="Gesamtgroesse der WAL-Dateien (falls verfuegbar)">
<div title="Total WAL directory size (when available)">
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
</div>
<div title="Optional ueber Agent/SSH ermittelbar">
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
<div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
<span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
</div>
<div title="Zeitliche Replikationsverzoegerung auf Standby">
<div title="Replication replay delay on standby">
<span>Replay Lag</span>
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
{formatSeconds(overview.replication.replay_lag_seconds)}
@@ -183,6 +432,12 @@ export function TargetDetailPage() {
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
<div title="Host CPU requires OS-level telemetry">
<span>Host CPU</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
<div title="Host RAM requires OS-level telemetry">
<span>Host RAM</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
</div>
<div className="grid two">
@@ -255,23 +510,43 @@ export function TargetDetailPage() {
</div>
)}
<div className="range-picker">
<button
type="button"
className={`live-btn ${liveMode ? "active" : ""}`}
onClick={() => {
setLiveMode((prev) => {
const next = !prev;
if (next) setRange("15m");
return next;
});
}}
>
LIVE
</button>
{Object.keys(ranges).map((r) => (
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
<button
key={r}
onClick={() => {
setLiveMode(false);
setRange(r);
}}
className={r === range ? "active" : ""}
>
{r}
</button>
))}
</div>
<div className="card" style={{ height: 320 }}>
<h3>Connections / TPS approx / Cache hit ratio</h3>
<h3>Connections / TPS (approx) / Cache Hit Ratio</h3>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chartData}>
<XAxis dataKey="ts" hide />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
<Tooltip content={<MetricsTooltip />} />
<Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} />
<Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} />
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" 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} isAnimationActive={false} />
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>

View File

@@ -1,9 +1,24 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "postgres",
username: "",
password: "",
sslmode: "prefer",
use_pg_stat_statements: true,
discover_all_databases: false,
owner_user_ids: [],
tags: {},
};
const emptyEditForm = {
id: null,
name: "",
host: "",
port: 5432,
@@ -11,22 +26,120 @@ const emptyForm = {
username: "",
password: "",
sslmode: "prefer",
tags: {},
use_pg_stat_statements: true,
owner_user_ids: [],
};
function toggleOwner(ids, userId) {
return ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId];
}
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
const pickerRef = useRef(null);
const [open, setOpen] = useState(false);
const filtered = candidates.filter((item) =>
item.email.toLowerCase().includes(query.trim().toLowerCase())
);
const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
useEffect(() => {
const onPointerDown = (event) => {
if (!pickerRef.current?.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
return (
<div className="owner-picker" ref={pickerRef}>
<div className="owner-selected">
{selected.length > 0 ? (
selected.map((item) => (
<button
key={`selected-${item.user_id}`}
type="button"
className="owner-selected-chip"
onClick={() => onToggle(item.user_id)}
title="Remove owner"
>
<span>{item.email}</span>
<span aria-hidden="true">x</span>
</button>
))
) : (
<span className="muted">No owners selected yet.</span>
)}
</div>
<div className={`owner-search-shell ${open ? "open" : ""}`}>
<input
type="text"
className="owner-search-input"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
placeholder="Search users by email..."
/>
<button type="button" className="owner-search-toggle" onClick={() => setOpen((prev) => !prev)}>
v
</button>
</div>
{open && (
<div className="owner-dropdown">
<div className="owner-search-results">
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
<button
key={item.user_id}
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div>
</div>
)}
</div>
);
}
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [editing, setEditing] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
const [saveState, setSaveState] = useState({ loading: false, message: "" });
const [ownerCandidates, setOwnerCandidates] = useState([]);
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
const [editOwnerQuery, setEditOwnerQuery] = useState("");
const canManage = me?.role === "admin" || me?.role === "operator";
const load = async () => {
setLoading(true);
try {
if (canManage) {
const [targetRows, candidates] = await Promise.all([
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/targets/owner-candidates", {}, tokens, refresh),
]);
setTargets(targetRows);
setOwnerCandidates(candidates);
} else {
setTargets(await apiFetch("/targets", {}, tokens, refresh));
}
setError("");
} catch (e) {
setError(String(e.message || e));
@@ -37,7 +150,7 @@ export function TargetsPage() {
useEffect(() => {
load();
}, []);
}, [canManage]);
const createTarget = async (e) => {
e.preventDefault();
@@ -50,8 +163,33 @@ export function TargetsPage() {
}
};
const testConnection = async () => {
setTestState({ loading: true, message: "", ok: null });
try {
const result = await apiFetch(
"/targets/test-connection",
{
method: "POST",
body: JSON.stringify({
host: form.host,
port: form.port,
dbname: form.dbname,
username: form.username,
password: form.password,
sslmode: form.sslmode,
}),
},
tokens,
refresh
);
setTestState({ loading: false, message: `${result.message} (PostgreSQL ${result.server_version})`, ok: true });
} catch (e) {
setTestState({ loading: false, message: String(e.message || e), ok: false });
}
};
const deleteTarget = async (id) => {
if (!confirm("Target löschen?")) return;
if (!confirm("Delete target?")) return;
try {
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
@@ -60,41 +198,105 @@ export function TargetsPage() {
}
};
const startEdit = (target) => {
setEditing(true);
setSaveState({ loading: false, message: "" });
setEditOwnerQuery("");
setEditForm({
id: target.id,
name: target.name,
host: target.host,
port: target.port,
dbname: target.dbname,
username: target.username,
password: "",
sslmode: target.sslmode,
use_pg_stat_statements: target.use_pg_stat_statements !== false,
owner_user_ids: Array.isArray(target.owner_user_ids) ? target.owner_user_ids : [],
});
};
const cancelEdit = () => {
setEditing(false);
setEditForm(emptyEditForm);
setSaveState({ loading: false, message: "" });
};
const saveEdit = async (e) => {
e.preventDefault();
if (!editForm.id) return;
setSaveState({ loading: true, message: "" });
try {
const payload = {
name: editForm.name,
host: editForm.host,
port: Number(editForm.port),
dbname: editForm.dbname,
username: editForm.username,
sslmode: editForm.sslmode,
use_pg_stat_statements: !!editForm.use_pg_stat_statements,
owner_user_ids: editForm.owner_user_ids || [],
};
if (editForm.password.trim()) payload.password = editForm.password;
await apiFetch(`/targets/${editForm.id}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
setSaveState({ loading: false, message: "Target updated." });
setEditing(false);
setEditForm(emptyEditForm);
await load();
} catch (e) {
setSaveState({ loading: false, message: String(e.message || e) });
}
};
return (
<div>
<div className="targets-page">
<h2>Targets Management</h2>
{error && <div className="card error">{error}</div>}
{canManage && (
<form className="card grid two" onSubmit={createTarget}>
<details className="card collapsible">
<summary className="collapse-head">
<div>
<h3>New Target</h3>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
<form className="target-form grid two" onSubmit={createTarget}>
<div className="field">
<label>Name</label>
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<small>Eindeutiger Anzeigename im Dashboard.</small>
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="field">
<label>Host</label>
<input placeholder="z.B. 172.16.0.106 oder db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
</div>
<div className="field">
<label>Port</label>
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
<small>Standard PostgreSQL Port ist 5432 (oder gemappter Host-Port).</small>
</div>
<div className="field">
<label>DB Name</label>
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<small>Name der Datenbank, die überwacht werden soll.</small>
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
<input
placeholder={form.discover_all_databases ? "e.g. postgres" : "e.g. postgres or appdb"}
value={form.dbname}
onChange={(e) => setForm({ ...form, dbname: e.target.value })}
required
/>
<small>
{form.discover_all_databases
? "Connection database used to crawl all available databases on this instance."
: "Single database to monitor for this target."}
</small>
</div>
<div className="field">
<label>Username</label>
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
<small>DB User mit Leserechten auf Stats-Views.</small>
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
</div>
<div className="field">
<label>Password</label>
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<small>Wird verschlüsselt in der Core-DB gespeichert.</small>
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
</div>
<div className="field">
<label>SSL Mode</label>
@@ -104,32 +306,162 @@ export function TargetsPage() {
<option value="require">require</option>
</select>
<small>
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
If you see "rejected SSL upgrade", switch to <code>disable</code>.
</small>
</div>
<div className="field">
<label>&nbsp;</label>
<button>Target anlegen</button>
<div className="field toggle-field">
<label>Query Insights Source</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!form.use_pg_stat_statements}
onChange={(e) => setForm({ ...form, use_pg_stat_statements: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Use pg_stat_statements for this target</strong>
<small>Disable this if the extension is unavailable on the target.</small>
</span>
</label>
</div>
<div className="field toggle-field">
<label>Scope</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!form.discover_all_databases}
onChange={(e) => setForm({ ...form, discover_all_databases: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Discover and add all databases</strong>
<small>Requires credentials with access to list databases (typically a superuser).</small>
</span>
</label>
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={form.owner_user_ids}
query={createOwnerQuery}
onQueryChange={setCreateOwnerQuery}
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">
<div className="form-actions">
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
{testState.loading ? "Testing..." : "Test connection"}
</button>
<button className="primary-btn">Create target</button>
</div>
</div>
</form>
{testState.message && (
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
)}
{canManage && (
<div className="card tips">
<strong>Troubleshooting</strong>
<p>
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
</p>
<p>
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
</p>
<p>
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
</p>
</details>
)}
{canManage && editing && (
<section className="card">
<h3>Edit Target</h3>
<form className="target-form grid two" onSubmit={saveEdit}>
<div className="field">
<label>Name</label>
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
</div>
<div className="field">
<label>Host</label>
<input value={editForm.host} onChange={(e) => setEditForm({ ...editForm, host: e.target.value })} required />
</div>
<div className="field">
<label>Port</label>
<input type="number" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: Number(e.target.value) })} required />
</div>
<div className="field">
<label>DB Name</label>
<input value={editForm.dbname} onChange={(e) => setEditForm({ ...editForm, dbname: e.target.value })} required />
</div>
<div className="field">
<label>Username</label>
<input value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} required />
</div>
<div className="field">
<label>New Password (optional)</label>
<input type="password" placeholder="Leave empty to keep current" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
</div>
<div className="field">
<label>SSL Mode</label>
<select value={editForm.sslmode} onChange={(e) => setEditForm({ ...editForm, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
</div>
<div className="field toggle-field">
<label>Query Insights Source</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!editForm.use_pg_stat_statements}
onChange={(e) => setEditForm({ ...editForm, use_pg_stat_statements: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Use pg_stat_statements for this target</strong>
<small>Disable this if the extension is unavailable on the target.</small>
</span>
</label>
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={editForm.owner_user_ids}
query={editOwnerQuery}
onQueryChange={setEditOwnerQuery}
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">
<div className="form-actions">
<button type="button" className="secondary-btn" onClick={cancelEdit}>Cancel</button>
<button className="primary-btn" disabled={saveState.loading}>{saveState.loading ? "Saving..." : "Save changes"}</button>
</div>
</div>
</form>
{saveState.message && <div className="test-connection-result">{saveState.message}</div>}
</section>
)}
<div className="card">
{canManage && (
<details className="card collapsible tips">
<summary className="collapse-head">
<div>
<h3>Troubleshooting</h3>
<p>Quick checks for the most common connection issues.</p>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
<p>
<code>Connection refused</code>: host/port is wrong or database is unreachable.
</p>
<p>
<code>rejected SSL upgrade</code>: set SSL mode to <code>disable</code>.
</p>
<p>
<code>localhost</code> points to the backend container itself, not your host machine.
</p>
</details>
)}
<div className="card targets-table">
{loading ? (
<p>Lade Targets...</p>
<p>Loading targets...</p>
) : (
<table>
<thead>
@@ -137,7 +469,9 @@ export function TargetsPage() {
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktionen</th>
<th>Owners</th>
<th>Query Insights</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -146,9 +480,43 @@ export function TargetsPage() {
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
<td>
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
</span>
</td>
<td>
<div className="row-actions">
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
</svg>
</span>
Details
</Link>
{canManage && (
<button className="table-action-btn edit" onClick={() => startEdit(t)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M3 17.25V21h3.75L19.81 7.94l-3.75-3.75L3 17.25zm2.92 2.33H5v-.92l10.06-10.06.92.92L5.92 19.58zM20.71 6.04a1 1 0 0 0 0-1.41L19.37 3.3a1 1 0 0 0-1.41 0l-1.13 1.12 3.75 3.75 1.13-1.13z" fill="currentColor" />
</svg>
</span>
Edit
</button>
)}
{canManage && (
<button className="table-action-btn delete" onClick={() => deleteTarget(t.id)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
</svg>
</span>
Delete
</button>
)}
</div>
</td>
</tr>
))}

View 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>
);
}

View File

@@ -1,7 +1,8 @@
import React, { createContext, useContext, useMemo, useState } from "react";
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { API_URL } from "./api";
const AuthCtx = createContext(null);
const UI_MODE_KEY = "nexapg_ui_mode";
function loadStorage() {
try {
@@ -11,10 +12,26 @@ function loadStorage() {
}
}
function loadUiMode() {
try {
const value = localStorage.getItem(UI_MODE_KEY);
if (value === "easy" || value === "dba") return value;
} catch {
// ignore storage errors
}
return "dba";
}
export function AuthProvider({ children }) {
const initial = loadStorage();
const [tokens, setTokens] = useState(initial?.tokens || null);
const [me, setMe] = useState(initial?.me || null);
const [uiMode, setUiModeState] = useState(loadUiMode);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [alertToasts, setAlertToasts] = useState([]);
const [serviceInfo, setServiceInfo] = useState(null);
const knownAlertKeysRef = useRef(new Set());
const hasAlertSnapshotRef = useRef(false);
const persist = (nextTokens, nextMe) => {
if (nextTokens && nextMe) {
@@ -78,7 +95,153 @@ export function AuthProvider({ children }) {
}
};
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
const dismissAlertToast = (toastId) => {
setAlertToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, closing: true } : t)));
setTimeout(() => {
setAlertToasts((prev) => prev.filter((t) => t.id !== toastId));
}, 220);
};
useEffect(() => {
if (!tokens?.accessToken) {
setAlertStatus({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
setAlertToasts([]);
knownAlertKeysRef.current = new Set();
hasAlertSnapshotRef.current = false;
return;
}
let mounted = true;
const pushToastsForNewItems = (items) => {
if (!items.length) return;
const createdAt = Date.now();
const nextToasts = items.slice(0, 4).map((item, idx) => ({
id: `${createdAt}-${idx}-${item.alert_key}`,
alertKey: item.alert_key,
severity: item.severity,
title: item.name,
target: item.target_name,
message: item.message,
closing: false,
}));
setAlertToasts((prev) => [...nextToasts, ...prev].slice(0, 6));
for (const toast of nextToasts) {
setTimeout(() => {
if (!mounted) return;
dismissAlertToast(toast.id);
}, 8000);
}
};
const loadAlertStatus = async () => {
const request = async (accessToken) =>
fetch(`${API_URL}/alerts/status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
let res = await request(tokens.accessToken);
if (res.status === 401 && tokens.refreshToken) {
const refreshed = await refresh();
if (refreshed?.accessToken) {
res = await request(refreshed.accessToken);
}
}
if (!res.ok) return;
const payload = await res.json();
if (!mounted) return;
setAlertStatus(payload);
const currentItems = [...(payload.warnings || []), ...(payload.alerts || [])];
const currentKeys = new Set(currentItems.map((item) => item.alert_key));
if (!hasAlertSnapshotRef.current) {
knownAlertKeysRef.current = currentKeys;
hasAlertSnapshotRef.current = true;
return;
}
const newItems = currentItems.filter((item) => !knownAlertKeysRef.current.has(item.alert_key));
knownAlertKeysRef.current = currentKeys;
pushToastsForNewItems(newItems);
};
loadAlertStatus().catch(() => {});
const timer = setInterval(() => {
loadAlertStatus().catch(() => {});
}, 8000);
return () => {
mounted = false;
clearInterval(timer);
};
}, [tokens?.accessToken, tokens?.refreshToken]);
useEffect(() => {
if (!tokens?.accessToken) {
setServiceInfo(null);
return;
}
let mounted = true;
const request = async (path, method = "GET") => {
const doFetch = async (accessToken) =>
fetch(`${API_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${accessToken}` },
});
let res = await doFetch(tokens.accessToken);
if (res.status === 401 && tokens.refreshToken) {
const refreshed = await refresh();
if (refreshed?.accessToken) {
res = await doFetch(refreshed.accessToken);
}
}
if (!res.ok) return null;
return res.json();
};
const runServiceCheck = async () => {
await request("/service/info/check", "POST");
const info = await request("/service/info", "GET");
if (mounted && info) setServiceInfo(info);
};
runServiceCheck().catch(() => {});
const timer = setInterval(() => {
runServiceCheck().catch(() => {});
}, 30000);
return () => {
mounted = false;
clearInterval(timer);
};
}, [tokens?.accessToken, tokens?.refreshToken]);
const setUiMode = (nextMode) => {
const mode = nextMode === "easy" ? "easy" : "dba";
setUiModeState(mode);
localStorage.setItem(UI_MODE_KEY, mode);
};
const value = useMemo(
() => ({
tokens,
me,
login,
logout,
refresh,
uiMode,
setUiMode,
alertStatus,
alertToasts,
dismissAlertToast,
serviceInfo,
serviceUpdateAvailable: !!serviceInfo?.update_available,
}),
[tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,59 @@
# App
# ------------------------------
# Application
# ------------------------------
# Display name used in API docs/UI.
APP_NAME=NexaPG Monitor
# Runtime environment: dev | staging | prod | test
ENVIRONMENT=dev
# Backend log level: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
# Core DB
# ------------------------------
# Core Database (internal metadata DB)
# ------------------------------
# Database that stores users, targets, metrics, query stats, and audit logs.
# DEV default only. Use strong unique credentials in production.
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=nexapg
# Host port mapped to the internal PostgreSQL container port 5432.
DB_PORT=5433
# Backend
# ------------------------------
# Backend API
# ------------------------------
# Host port mapped to backend container port 8000.
BACKEND_PORT=8000
# JWT signing secret. Never hardcode in source. Rotate regularly.
JWT_SECRET_KEY=change_this_super_secret
JWT_ALGORITHM=HS256
# Access token lifetime in minutes.
JWT_ACCESS_TOKEN_MINUTES=15
# Refresh token lifetime in minutes (10080 = 7 days).
JWT_REFRESH_TOKEN_MINUTES=10080
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Key used to encrypt monitored target passwords at rest.
# Never hardcode in source. Rotate with re-encryption plan.
# Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
# Allowed CORS origins for browser clients.
# Use comma-separated values, e.g.:
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
# Dev-only shortcut:
# CORS_ORIGINS=*
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
# Target polling interval in seconds.
POLL_INTERVAL_SECONDS=30
# Initial admin bootstrap user (created on first startup if not present).
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# ------------------------------
# Frontend
# ------------------------------
# Host port mapped to frontend container port 8080.
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

View File

@@ -0,0 +1,48 @@
# NexaPG production profile (reverse proxy + HTTPS)
# Copy to .env and adjust values for your environment.
# ------------------------------
# Application
# ------------------------------
APP_NAME=NexaPG Monitor
ENVIRONMENT=prod
LOG_LEVEL=INFO
# ------------------------------
# Core Database
# ------------------------------
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=change_me
DB_PORT=5433
# ------------------------------
# Backend
# ------------------------------
BACKEND_PORT=8000
JWT_SECRET_KEY=replace_with_long_random_secret
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_MINUTES=15
JWT_REFRESH_TOKEN_MINUTES=10080
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
# Production CORS:
# - no wildcard
# - set exact public UI origin(s)
CORS_ORIGINS=https://monitor.example.com
POLL_INTERVAL_SECONDS=30
ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5
ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15
ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100
ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# ------------------------------
# Frontend
# ------------------------------
# Keep frontend API base relative to avoid HTTPS mixed-content.
FRONTEND_PORT=5173
VITE_API_URL=/api/v1

View File

@@ -0,0 +1,49 @@
# NGINX reverse proxy profile for NexaPG (HTTPS).
# Replace monitor.example.com and certificate paths.
server {
listen 80;
server_name monitor.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name monitor.example.com;
ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Baseline security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Frontend app
location / {
proxy_pass http://127.0.0.1:5173;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# API forwarding to backend
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
}

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# bash bootstrap-compose.sh
# BASE_URL="https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main" bash bootstrap-compose.sh
BASE_URL="${BASE_URL:-https://git.nesterovic.cc/nessi/NexaPG/raw/branch/main}"
echo "[bootstrap] Using base URL: ${BASE_URL}"
fetch_file() {
local path="$1"
local out="$2"
if command -v wget >/dev/null 2>&1; then
wget -q -O "${out}" "${BASE_URL}/${path}"
elif command -v curl >/dev/null 2>&1; then
curl -fsSL "${BASE_URL}/${path}" -o "${out}"
else
echo "[bootstrap] ERROR: wget or curl is required"
exit 1
fi
}
fetch_file "docker-compose.yml" "docker-compose.yml"
fetch_file ".env.example" ".env.example"
fetch_file "Makefile" "Makefile"
if [[ ! -f ".env" ]]; then
cp .env.example .env
echo "[bootstrap] Created .env from .env.example"
else
echo "[bootstrap] .env already exists, keeping it"
fi
echo
echo "[bootstrap] Next steps:"
echo " 1) Edit .env (set JWT_SECRET_KEY and ENCRYPTION_KEY at minimum)"
echo " 2) Run: make up"
echo

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[proxy-profile] validating reverse-proxy and mixed-content guardrails"
require_pattern() {
local file="$1"
local pattern="$2"
local message="$3"
if ! grep -Eq "$pattern" "$file"; then
echo "[proxy-profile] FAIL: $message ($file)"
exit 1
fi
}
# Frontend should default to relative API base in container builds.
require_pattern "frontend/Dockerfile" "ARG VITE_API_URL=/api/v1" \
"VITE_API_URL default must be relative (/api/v1)"
# Frontend runtime proxy should forward /api with forward headers.
require_pattern "frontend/nginx.conf" "location /api/" \
"frontend nginx must proxy /api/"
require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-Proto" \
"frontend nginx must set X-Forwarded-Proto"
require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-For" \
"frontend nginx must set X-Forwarded-For"
require_pattern "frontend/nginx.conf" "proxy_set_header Host" \
"frontend nginx must forward Host"
# Mixed-content guard in frontend API client.
require_pattern "frontend/src/api.js" "window\\.location\\.protocol === \"https:\".*parsed\\.protocol === \"http:\"" \
"frontend api client must contain HTTPS mixed-content protection"
# Production profile must not use wildcard CORS.
require_pattern "ops/profiles/prod/.env.production.example" "^CORS_ORIGINS=https://[^*]+$" \
"production profile must use explicit HTTPS CORS origins"
echo "[proxy-profile] PASS"

View File

@@ -0,0 +1,3 @@
{
"entries": []
}