94 Commits

Author SHA1 Message Date
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
71 changed files with 12848 additions and 449 deletions

View File

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

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

@@ -0,0 +1,107 @@
name: Docker Publish (Release)
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version tag to publish (e.g. 0.1.2 or v0.1.2)"
required: false
type: string
jobs:
publish:
name: Build and Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
env:
# Optional repo variable. If unset, DOCKERHUB_USERNAME is used.
IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version/tag
id: ver
shell: bash
run: |
RAW_TAG="${{ github.event.release.tag_name }}"
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${{ inputs.version }}"
fi
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${GITHUB_REF_NAME}"
fi
CLEAN_TAG="${RAW_TAG#v}"
echo "raw=$RAW_TAG" >> "$GITHUB_OUTPUT"
echo "clean=$CLEAN_TAG" >> "$GITHUB_OUTPUT"
- name: Set image namespace
id: ns
shell: bash
run: |
NS="${IMAGE_NAMESPACE}"
if [ -z "$NS" ]; then
NS="${{ secrets.DOCKERHUB_USERNAME }}"
fi
if [ -z "$NS" ]; then
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
exit 1
fi
echo "value=$NS" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: true
provenance: mode=max
sbom: true
labels: |
org.opencontainers.image.title=NexaPG Backend
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
tags: |
${{ steps.ns.outputs.value }}/nexapg-backend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-backend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache,mode=max
- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
provenance: mode=max
sbom: true
build-args: |
VITE_API_URL=/api/v1
labels: |
org.opencontainers.image.title=NexaPG Frontend
org.opencontainers.image.vendor=Nesterovic IT-Services e.U.
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.version=${{ steps.ver.outputs.clean }}
tags: |
${{ steps.ns.outputs.value }}/nexapg-frontend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-frontend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache,mode=max

86
.github/workflows/migration-safety.yml vendored Normal file
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

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

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

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

456
README.md
View File

@@ -1,113 +1,423 @@
# NexaPG - PostgreSQL Monitoring Stack
# NexaPG
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
<p align="center">
<img src="frontend/public/nexapg-logo.svg" alt="NexaPG Logo" width="180" />
</p>
## Features
NexaPG is a full-stack PostgreSQL monitoring platform for multiple remote targets.
It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, polling collectors, query insights, alerting, and target-owner email notifications.
- Multi-target PostgreSQL Monitoring (remote)
- Polling Collector fuer:
- `pg_stat_database`
- `pg_stat_activity`
- `pg_stat_bgwriter`
- `pg_locks`
- `pg_stat_statements` (falls auf Target aktiviert)
- Core-DB fuer:
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
- Targets (Credentials verschluesselt via Fernet)
- Metrics / Query Stats
- Audit Logs
- Auth mit JWT Access/Refresh Tokens
- FastAPI + SQLAlchemy async + Alembic
- React (Vite) Frontend mit:
- Login/Logout
- Dashboard
- Target Detail mit Charts
- Query Insights
- Admin User Management
- Health Endpoints:
- `/api/v1/healthz`
- `/api/v1/readyz`
## Table of Contents
## Struktur
- [Quick Deploy (Prebuilt Images)](#quick-deploy-prebuilt-images)
- [Prerequisites](#prerequisites)
- [Make Commands](#make-commands)
- [Configuration Reference (`.env`)](#configuration-reference-env)
- [Core Functional Areas](#core-functional-areas)
- [Service Information](#service-information)
- [Target Owner Notifications](#target-owner-notifications)
- [API Overview](#api-overview)
- [API Error Format](#api-error-format)
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
- [Troubleshooting](#troubleshooting)
- [Security Notes](#security-notes)
- `backend/` FastAPI App
- `frontend/` React (Vite) App
- `ops/` Scripts
- `docker-compose.yml` Stack
- `.env.example` Konfigurationsvorlage
## Highlights
## Schnellstart
- Multi-target monitoring for remote PostgreSQL instances
- Optional one-click target onboarding for "all databases" discovery on an instance
- PostgreSQL compatibility support: `14`, `15`, `16`, `17`, `18`
- JWT auth (`access` + `refresh`) and RBAC (`admin`, `operator`, `viewer`)
- Polling collector for metrics, locks, activity, and optional `pg_stat_statements`
- Target detail overview (instance, storage, replication, core performance metrics)
- Alerts system:
- standard built-in alerts
- custom SQL alerts (admin/operator)
- warning + alert severities
- real-time UI updates + toast notifications
- Target owners: alert emails are sent only to responsible users assigned to a target
- SMTP settings in admin UI (send-only) with test mail support
- Structured backend logs + audit logs
1. Env-Datei erstellen:
## UI Preview
### Dashboard
![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 `80` |
## Core Functional Areas
### Targets
- Create, list, edit, delete targets
- Test target connection before save
- Optional "discover all databases" mode (creates one monitored target per discovered DB)
- Configure SSL mode per target
- Toggle `pg_stat_statements` usage per target
- Assign responsible users (target owners)
### Target Details
- Database Overview section with instance, role, uptime, size, replication, and core metrics
- Metric charts with range selection and live mode
- Locks and activity tables
### Query Insights
- Uses collected `pg_stat_statements` data
- Ranking and categorization views
- Search and pagination
- Disabled automatically for targets where query insights flag is off
### Alerts
- Warning and alert severity split
- Expandable alert cards with details and recommended actions
- Custom alert definitions (SQL + thresholds)
- Real-time refresh and in-app toast notifications
### Admin Settings
- User management (RBAC)
- SMTP settings for outgoing alert mails:
- enable/disable
- host/port/auth
- STARTTLS / SSL mode
- from email + from name
- recipient test mail
### Service Information
- Sidebar entry for runtime and system details
- Displays current version, latest known version, uptime, host, and platform
- "Check for Updates" against the latest published release in the official upstream repository (`git.nesterovic.cc/nessi/NexaPG`)
- Version/update source are read-only in UI (maintainer-controlled in code/release flow)
- Local displayed version is code-defined in `backend/app/core/config.py` (`NEXAPG_VERSION`) and not configurable via `.env`
## Target Owner Notifications
Email alert routing is target-specific:
- only users assigned as owners for a target receive that target's alert emails
- supports multiple owners per target
- notification sending is throttled to reduce repeated alert spam
## API Overview
### Health
- `GET /api/v1/healthz`
- `GET /api/v1/readyz`
### Auth
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `POST /api/v1/auth/logout`
- `GET /api/v1/me`
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
### Targets
- `GET /api/v1/targets`
- `POST /api/v1/targets`
- `POST /api/v1/targets/test-connection`
- `GET /api/v1/targets/{id}`
- `PUT /api/v1/targets/{id}`
- `DELETE /api/v1/targets/{id}`
- `GET /api/v1/targets/{id}/owners`
- `PUT /api/v1/targets/{id}/owners`
- `GET /api/v1/targets/owner-candidates`
- `GET /api/v1/targets/{id}/metrics`
- `GET /api/v1/targets/{id}/locks`
- `GET /api/v1/targets/{id}/activity`
- `GET /api/v1/targets/{id}/top-queries`
- Admin-only CRUD users:
- `GET /api/v1/admin/users`
- `POST /api/v1/admin/users`
- `PUT /api/v1/admin/users/{user_id}`
- `DELETE /api/v1/admin/users/{user_id}`
- `GET /api/v1/targets/{id}/overview`
## Security Notes
### Alerts
- Keine Secrets hardcoded
- Passwoerter als Argon2 Hash
- Target-Credentials verschluesselt (Fernet)
- CORS via Env steuerbar
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
- `GET /api/v1/alerts/status`
- `GET /api/v1/alerts/definitions`
- `POST /api/v1/alerts/definitions`
- `PUT /api/v1/alerts/definitions/{id}`
- `DELETE /api/v1/alerts/definitions/{id}`
- `POST /api/v1/alerts/definitions/test`
## Wichtiger Hinweis zu `pg_stat_statements`
### Admin
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
Beispiel:
- `GET /api/v1/admin/users`
- `POST /api/v1/admin/users`
- `PUT /api/v1/admin/users/{user_id}`
- `DELETE /api/v1/admin/users/{user_id}`
- `GET /api/v1/admin/settings/email`
- `PUT /api/v1/admin/settings/email`
- `POST /api/v1/admin/settings/email/test`
### Service Information
- `GET /api/v1/service/info`
- `POST /api/v1/service/info/check`
## API Error Format
All 4xx/5xx responses use a consistent JSON payload:
```json
{
"code": "validation_error",
"message": "Request validation failed",
"details": [],
"request_id": "c8f0f888-2365-4b86-a5de-b3f0e9df4a4b"
}
```
Common fields:
- `code`: stable machine-readable error code
- `message`: human-readable summary
- `details`: optional extra context (validation list, debug context, etc.)
- `request_id`: request correlation ID (also returned in `X-Request-ID` header)
Common error codes:
- `bad_request` (`400`)
- `unauthorized` (`401`)
- `forbidden` (`403`)
- `not_found` (`404`)
- `conflict` (`409`)
- `validation_error` (`422`)
- `target_unreachable` (`503`)
- `internal_error` (`500`)
## `pg_stat_statements` Requirement
Query Insights requires `pg_stat_statements` on the monitored target:
```sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```
If unavailable, disable it per target in target settings.
## Reverse Proxy / SSL Guidance
For production, serve frontend and API under the same public origin via reverse proxy.
- Frontend URL example: `https://monitor.example.com`
- Proxy API path `/api/` to backend service
- Route `/api/v1` to the backend service
This prevents mixed-content and CORS issues.
## PostgreSQL Compatibility Smoke Test
Run manually against one DSN:
```bash
PG_DSN='postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
python backend/scripts/pg_compat_smoke.py
```
Run with DSN candidates (CI style):
```bash
PG_DSN_CANDIDATES='postgresql://postgres:postgres@postgres:5432/compatdb?sslmode=disable,postgresql://postgres:postgres@127.0.0.1:5432/compatdb?sslmode=disable' \
python backend/scripts/pg_compat_smoke.py
```
## Troubleshooting
### Backend container keeps restarting during `make migrate`
Most common reason: failed migration. Check logs:
```bash
docker compose logs --tail=200 backend
docker compose logs --tail=200 db
```
### CORS or mixed-content issues behind SSL proxy
- Ensure proxy forwards `/api/` (or `/api/v1`) to backend
- Set correct frontend origin(s) in `CORS_ORIGINS`
### `rejected SSL upgrade` for a target
Target likely does not support SSL with current settings.
Set target `sslmode` to `disable` (or correct SSL config on target DB).
### Query Insights empty
- Check target has `Use pg_stat_statements` enabled
- Verify extension exists on target (`CREATE EXTENSION ...`)
## Security Notes
- No secrets hardcoded in repository
- Passwords hashed with Argon2
- Sensitive values encrypted at rest (Fernet)
- RBAC enforced on protected endpoints
- Audit logs for critical actions
- Collector error logging includes throttling to reduce repeated noise

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

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

@@ -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:

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

@@ -7,8 +7,9 @@ ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.29-alpine
FROM nginxinc/nginx-unprivileged:stable-alpine
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,57 @@
# App
# ------------------------------
# Application
# ------------------------------
# Display name used in API docs/UI.
APP_NAME=NexaPG Monitor
# Runtime environment: dev | staging | prod | test
ENVIRONMENT=dev
# Backend log level: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
# Core DB
# ------------------------------
# Core Database (internal metadata DB)
# ------------------------------
# Database that stores users, targets, metrics, query stats, and audit logs.
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=nexapg
# Host port mapped to the internal PostgreSQL container port 5432.
DB_PORT=5433
# Backend
# ------------------------------
# Backend API
# ------------------------------
# Host port mapped to backend container port 8000.
BACKEND_PORT=8000
# JWT signing secret. Change this in every non-local environment.
JWT_SECRET_KEY=change_this_super_secret
JWT_ALGORITHM=HS256
# Access token lifetime in minutes.
JWT_ACCESS_TOKEN_MINUTES=15
# Refresh token lifetime in minutes (10080 = 7 days).
JWT_REFRESH_TOKEN_MINUTES=10080
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Key used to encrypt monitored target passwords at rest.
# Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
# Allowed CORS origins for browser clients.
# Use comma-separated values, e.g.:
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
# Dev-only shortcut:
# CORS_ORIGINS=*
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
# Target polling interval in seconds.
POLL_INTERVAL_SECONDS=30
# Initial admin bootstrap user (created on first startup if not present).
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# ------------------------------
# Frontend
# ------------------------------
# Host port mapped to frontend container port 80.
FRONTEND_PORT=5173
VITE_API_URL=http://localhost:8000/api/v1
# Base API URL used at frontend build time.
# For reverse proxy + SSL, keep this relative to avoid mixed-content issues.
# Example direct mode: VITE_API_URL=http://localhost:8000/api/v1
VITE_API_URL=/api/v1

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