19 Commits
0.1.0 ... 0.1.7

Author SHA1 Message Date
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
26 changed files with 779 additions and 118 deletions

View File

@@ -3,8 +3,6 @@
# ------------------------------ # ------------------------------
# Display name used in API docs/UI. # Display name used in API docs/UI.
APP_NAME=NexaPG Monitor APP_NAME=NexaPG Monitor
# Manual version string shown in Service Information page.
APP_VERSION=0.1.0
# Runtime environment: dev | staging | prod | test # Runtime environment: dev | staging | prod | test
ENVIRONMENT=dev ENVIRONMENT=dev
# Backend log level: DEBUG | INFO | WARNING | ERROR # Backend log level: DEBUG | INFO | WARNING | ERROR
@@ -60,7 +58,3 @@ INIT_ADMIN_PASSWORD=ChangeMe123!
# ------------------------------ # ------------------------------
# Host port mapped to frontend container port 80. # Host port mapped to frontend container port 80.
FRONTEND_PORT=5173 FRONTEND_PORT=5173
# 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

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

@@ -0,0 +1,91 @@
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
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
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
build-args: |
VITE_API_URL=/api/v1
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

View File

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

View File

@@ -9,7 +9,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
## Table of Contents ## Table of Contents
- [Quick Start](#quick-start) - [Quick Deploy (Prebuilt Images)](#quick-deploy-prebuilt-images)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Make Commands](#make-commands) - [Make Commands](#make-commands)
- [Configuration Reference (`.env`)](#configuration-reference-env) - [Configuration Reference (`.env`)](#configuration-reference-env)
@@ -93,27 +93,50 @@ Optional:
- `psql` for manual DB checks - `psql` for manual DB checks
## Quick Start ## Quick Deploy (Prebuilt Images)
1. Copy environment template: If you only want to run NexaPG from published Docker Hub images, use the bootstrap script:
```bash ```bash
cp .env.example .env 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
``` ```
2. Generate a Fernet key and set `ENCRYPTION_KEY` in `.env`: This downloads:
- `docker-compose.yml`
- `.env.example`
- `Makefile`
Then:
```bash ```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())" 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
3. Start the stack:
```bash
make up make up
``` ```
4. Open the application: 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
```
`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>` - Frontend: `http://<SERVER_IP>:<FRONTEND_PORT>`
- API base: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1` - API base: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
@@ -127,7 +150,7 @@ Initial admin bootstrap user (created from `.env` if missing):
## Make Commands ## Make Commands
```bash ```bash
make up # build and start all services make up # pull latest images and start all services
make down # stop all services make down # stop all services
make logs # follow compose logs make logs # follow compose logs
make migrate # optional/manual: run alembic upgrade head in backend container make migrate # optional/manual: run alembic upgrade head in backend container
@@ -142,7 +165,6 @@ Note: Migrations run automatically when the backend container starts (`entrypoin
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `APP_NAME` | Application display name | | `APP_NAME` | Application display name |
| `APP_VERSION` | Displayed NexaPG version in Service Information |
| `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) | | `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) |
| `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | | `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
@@ -184,12 +206,6 @@ Note: Migrations run automatically when the backend container starts (`entrypoin
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `FRONTEND_PORT` | Host port mapped to frontend container port `80` | | `FRONTEND_PORT` | Host port mapped to frontend container port `80` |
| `VITE_API_URL` | Frontend API base URL (build-time) |
Recommended values for `VITE_API_URL`:
- Reverse proxy setup: `/api/v1`
- Direct backend access: `http://<SERVER_IP>:<BACKEND_PORT>/api/v1`
## Core Functional Areas ## Core Functional Areas
@@ -236,8 +252,9 @@ Recommended values for `VITE_API_URL`:
- Sidebar entry for runtime and system details - Sidebar entry for runtime and system details
- Displays current version, latest known version, uptime, host, and platform - Displays current version, latest known version, uptime, host, and platform
- "Check for Updates" against the official upstream repository (`git.nesterovic.cc/nessi/NexaPG`) - "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) - 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 ## Target Owner Notifications
@@ -318,7 +335,7 @@ For production, serve frontend and API under the same public origin via reverse
- Frontend URL example: `https://monitor.example.com` - Frontend URL example: `https://monitor.example.com`
- Proxy API path `/api/` to backend service - Proxy API path `/api/` to backend service
- Use `VITE_API_URL=/api/v1` - Route `/api/v1` to the backend service
This prevents mixed-content and CORS issues. This prevents mixed-content and CORS issues.
@@ -351,8 +368,7 @@ docker compose logs --tail=200 db
### CORS or mixed-content issues behind SSL proxy ### CORS or mixed-content issues behind SSL proxy
- Set `VITE_API_URL=/api/v1` - Ensure proxy forwards `/api/` (or `/api/v1`) to backend
- Ensure proxy forwards `/api/` to backend
- Set correct frontend origin(s) in `CORS_ORIGINS` - Set correct frontend origin(s) in `CORS_ORIGINS`
### `rejected SSL upgrade` for a target ### `rejected SSL upgrade` for a target

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim AS base FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
@@ -6,6 +6,10 @@ ENV PIP_NO_CACHE_DIR=1
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system app && adduser --system --ingroup app app RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt

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

@@ -23,7 +23,13 @@ async def create_user(payload: UserCreate, admin: User = Depends(require_roles("
exists = await db.scalar(select(User).where(User.email == payload.email)) exists = await db.scalar(select(User).where(User.email == payload.email))
if exists: if exists:
raise HTTPException(status_code=409, detail="Email already exists") raise HTTPException(status_code=409, detail="Email already exists")
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role) user = User(
email=payload.email,
first_name=payload.first_name,
last_name=payload.last_name,
password_hash=hash_password(payload.password),
role=payload.role,
)
db.add(user) db.add(user)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
@@ -42,8 +48,15 @@ async def update_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
if "password" in update_data and update_data["password"]: next_email = update_data.get("email")
user.password_hash = hash_password(update_data.pop("password")) if next_email and next_email != user.email:
existing = await db.scalar(select(User).where(User.email == next_email))
if existing and existing.id != user.id:
raise HTTPException(status_code=409, detail="Email already exists")
if "password" in update_data:
raw_password = update_data.pop("password")
if raw_password:
user.password_hash = hash_password(raw_password)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(user, key, value) setattr(user, key, value)
await db.commit() await db.commit()

View File

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

View File

@@ -11,7 +11,6 @@ from app.core.db import get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.models import ServiceInfoSettings, User from app.models.models import ServiceInfoSettings, User
from app.schemas.service_info import ServiceInfoCheckResult, ServiceInfoOut from app.schemas.service_info import ServiceInfoCheckResult, ServiceInfoOut
from app.services.audit import write_audit_log
from app.services.service_info import ( from app.services.service_info import (
UPSTREAM_REPO_WEB, UPSTREAM_REPO_WEB,
fetch_latest_from_upstream, fetch_latest_from_upstream,
@@ -71,6 +70,7 @@ async def check_service_version(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ServiceInfoCheckResult: ) -> ServiceInfoCheckResult:
_ = user
row = await _get_or_create_service_settings(db) row = await _get_or_create_service_settings(db)
check_time = utcnow() check_time = utcnow()
latest, latest_ref, error = await fetch_latest_from_upstream() latest, latest_ref, error = await fetch_latest_from_upstream()
@@ -85,17 +85,6 @@ async def check_service_version(
row.update_available = False row.update_available = False
await db.commit() await db.commit()
await db.refresh(row) await db.refresh(row)
await write_audit_log(
db,
"service.info.check",
user.id,
{
"latest_version": row.latest_version,
"latest_ref": row.release_check_url,
"update_available": row.update_available,
"last_check_error": row.last_check_error,
},
)
return ServiceInfoCheckResult( return ServiceInfoCheckResult(
latest_version=row.latest_version, latest_version=row.latest_version,
latest_ref=(row.release_check_url or None), latest_ref=(row.release_check_url or None),

View File

@@ -2,12 +2,13 @@ from functools import lru_cache
from pydantic import field_validator from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
NEXAPG_VERSION = "0.1.7"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "NexaPG Monitor" app_name: str = "NexaPG Monitor"
app_version: str = "0.1.0"
environment: str = "dev" environment: str = "dev"
api_v1_prefix: str = "/api/v1" api_v1_prefix: str = "/api/v1"
log_level: str = "INFO" log_level: str = "INFO"
@@ -33,6 +34,10 @@ class Settings(BaseSettings):
init_admin_email: str = "admin@example.com" init_admin_email: str = "admin@example.com"
init_admin_password: str = "ChangeMe123!" init_admin_password: str = "ChangeMe123!"
@property
def app_version(self) -> str:
return NEXAPG_VERSION
@property @property
def database_url(self) -> str: def database_url(self) -> str:
return ( return (

View File

@@ -9,6 +9,8 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
first_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer") role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

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

View File

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

View File

@@ -65,8 +65,6 @@ def _get_json(url: str):
def _fetch_latest_from_upstream_sync() -> tuple[str, str]: def _fetch_latest_from_upstream_sync() -> tuple[str, str]:
latest_release_url = f"{UPSTREAM_REPO_API}/releases/latest" latest_release_url = f"{UPSTREAM_REPO_API}/releases/latest"
tags_url = f"{UPSTREAM_REPO_API}/tags?page=1&limit=1"
commits_url = f"{UPSTREAM_REPO_API}/commits?sha=main&page=1&limit=1"
try: try:
release = _get_json(latest_release_url) release = _get_json(latest_release_url)
@@ -74,27 +72,9 @@ def _fetch_latest_from_upstream_sync() -> tuple[str, str]:
tag = (release.get("tag_name") or release.get("name") or "").strip() tag = (release.get("tag_name") or release.get("name") or "").strip()
if tag: if tag:
return tag[:64], "release" return tag[:64], "release"
except Exception: except Exception as exc:
pass raise ValueError(f"Could not fetch latest release from upstream repository: {exc}") from exc
raise ValueError("No published release found in upstream repository")
try:
tags = _get_json(tags_url)
if isinstance(tags, list) and tags:
first = tags[0] if isinstance(tags[0], dict) else {}
tag = (first.get("name") or "").strip()
if tag:
return tag[:64], "tag"
except Exception:
pass
commits = _get_json(commits_url)
if isinstance(commits, list) and commits:
first = commits[0] if isinstance(commits[0], dict) else {}
sha = (first.get("sha") or "").strip()
if sha:
short = sha[:7]
return f"commit-{short}", "commit"
raise ValueError("Could not fetch release/tag/commit from upstream repository")
async def fetch_latest_from_upstream() -> tuple[str | None, str | None, str | None]: async def fetch_latest_from_upstream() -> tuple[str | None, str | None, str | None]:

View File

@@ -1,4 +1,5 @@
fastapi==0.116.1 fastapi==0.129.0
starlette==0.52.1
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
gunicorn==23.0.0 gunicorn==23.0.0
sqlalchemy[asyncio]==2.0.44 sqlalchemy[asyncio]==2.0.44
@@ -9,5 +10,6 @@ pydantic-settings==2.11.0
email-validator==2.2.0 email-validator==2.2.0
python-jose[cryptography]==3.5.0 python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4 passlib[argon2]==1.7.4
cryptography==45.0.7 cryptography==46.0.5
python-multipart==0.0.20 python-multipart==0.0.22
ecdsa==0.19.1

View File

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

View File

@@ -7,8 +7,9 @@ ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build RUN npm run build
FROM nginx:1.29-alpine FROM nginx:1.29-alpine-slim
RUN apk upgrade --no-cache
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1 HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD nginx -t || exit 1

View File

@@ -9,6 +9,7 @@ import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AlertsPage } from "./pages/AlertsPage"; import { AlertsPage } from "./pages/AlertsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage"; import { AdminUsersPage } from "./pages/AdminUsersPage";
import { ServiceInfoPage } from "./pages/ServiceInfoPage"; import { ServiceInfoPage } from "./pages/ServiceInfoPage";
import { UserSettingsPage } from "./pages/UserSettingsPage";
function Protected({ children }) { function Protected({ children }) {
const { tokens } = useAuth(); const { tokens } = useAuth();
@@ -18,9 +19,10 @@ function Protected({ children }) {
} }
function Layout({ children }) { function Layout({ children }) {
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast } = useAuth(); const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`; const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
const fullName = [me?.first_name, me?.last_name].filter(Boolean).join(" ").trim();
return ( return (
<div className="shell"> <div className="shell">
@@ -62,7 +64,10 @@ function Layout({ children }) {
</span> </span>
<span className="nav-label">Alerts</span> <span className="nav-label">Alerts</span>
</NavLink> </NavLink>
<NavLink to="/service-info" className={navClass}> <NavLink
to="/service-info"
className={({ isActive }) => `nav-btn${isActive ? " active" : ""}${serviceUpdateAvailable ? " update-available" : ""}`}
>
<span className="nav-icon" aria-hidden="true"> <span className="nav-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"> <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" /> <path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm0-11v6m0-10h.01" />
@@ -97,8 +102,12 @@ function Layout({ children }) {
</button> </button>
<small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small> <small>{uiMode === "easy" ? "Simple health guidance" : "Advanced DBA metrics"}</small>
</div> </div>
<div>{me?.email}</div> <div className="profile-name">{fullName || me?.email}</div>
<div className="role">{me?.role}</div> {fullName && <div className="profile-email">{me?.email}</div>}
<div className="role profile-role">{me?.role}</div>
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
User Settings
</NavLink>
<button className="logout-btn" onClick={logout}>Logout</button> <button className="logout-btn" onClick={logout}>Logout</button>
</div> </div>
</aside> </aside>
@@ -160,6 +169,7 @@ export function App() {
<Route path="/query-insights" element={<QueryInsightsPage />} /> <Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/alerts" element={<AlertsPage />} /> <Route path="/alerts" element={<AlertsPage />} />
<Route path="/service-info" element={<ServiceInfoPage />} /> <Route path="/service-info" element={<ServiceInfoPage />} />
<Route path="/user-settings" element={<UserSettingsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@@ -19,8 +19,11 @@ const TEMPLATE_VARIABLES = [
export function AdminUsersPage() { export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth(); const { tokens, refresh, me } = useAuth();
const emptyCreateForm = { email: "", first_name: "", last_name: "", password: "", role: "viewer" };
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" }); const [form, setForm] = useState(emptyCreateForm);
const [editingUserId, setEditingUserId] = useState(null);
const [editForm, setEditForm] = useState({ email: "", first_name: "", last_name: "", password: "", role: "viewer" });
const [emailSettings, setEmailSettings] = useState({ const [emailSettings, setEmailSettings] = useState({
enabled: false, enabled: false,
smtp_host: "", smtp_host: "",
@@ -79,7 +82,7 @@ export function AdminUsersPage() {
e.preventDefault(); e.preventDefault();
try { try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh); await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" }); setForm(emptyCreateForm);
await load(); await load();
} catch (e) { } catch (e) {
setError(String(e.message || e)); setError(String(e.message || e));
@@ -95,6 +98,39 @@ 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) => { const saveSmtp = async (e) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@@ -165,6 +201,22 @@ export function AdminUsersPage() {
<p className="muted">Create accounts and manage access roles.</p> <p className="muted">Create accounts and manage access roles.</p>
</div> </div>
<form className="grid three admin-user-form" onSubmit={create}> <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"> <div className="admin-field">
<label>Email</label> <label>Email</label>
<input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} /> <input value={form.email} placeholder="user@example.com" onChange={(e) => setForm({ ...form, email: e.target.value })} />
@@ -197,6 +249,7 @@ export function AdminUsersPage() {
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Name</th>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>Action</th> <th>Action</th>
@@ -206,11 +259,70 @@ export function AdminUsersPage() {
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} className="admin-user-row"> <tr key={u.id} className="admin-user-row">
<td className="user-col-id">{u.id}</td> <td className="user-col-id">{u.id}</td>
<td className="user-col-email">{u.email}</td> <td className="user-col-name">
<td> {editingUserId === u.id ? (
<span className={`pill role-pill role-${u.role}`}>{u.role}</span> <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>
<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 && ( {u.id !== me.id && (
<button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}> <button className="table-action-btn delete small-btn" onClick={() => remove(u.id)}>
<span aria-hidden="true"> <span aria-hidden="true">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { useAuth } from "../state"; import { useAuth } from "../state";
@@ -62,6 +62,7 @@ function buildQueryTips(row) {
export function QueryInsightsPage() { export function QueryInsightsPage() {
const { tokens, refresh } = useAuth(); const { tokens, refresh } = useAuth();
const refreshRef = useRef(refresh);
const [targets, setTargets] = useState([]); const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState(""); const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
@@ -71,6 +72,10 @@ export function QueryInsightsPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
@@ -89,17 +94,26 @@ export function QueryInsightsPage() {
useEffect(() => { useEffect(() => {
if (!targetId) return; if (!targetId) return;
let active = true;
(async () => { (async () => {
try { try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh); const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refreshRef.current);
if (!active) return;
setRows(data); setRows(data);
setSelectedQuery(data[0] || null); setSelectedQuery((prev) => {
setPage(1); if (!prev) return data[0] || null;
const keep = data.find((row) => row.queryid === prev.queryid);
return keep || data[0] || null;
});
setPage((prev) => (prev === 1 ? prev : 1));
} catch (e) { } catch (e) {
setError(String(e.message || e)); if (active) setError(String(e.message || e));
} }
})(); })();
}, [targetId, tokens, refresh]); return () => {
active = false;
};
}, [targetId, tokens?.accessToken, tokens?.refreshToken]);
const dedupedByQueryId = [...rows].reduce((acc, row) => { const dedupedByQueryId = [...rows].reduce((acc, row) => {
if (!row?.queryid) return acc; if (!row?.queryid) return acc;

View File

@@ -14,7 +14,7 @@ function formatUptime(seconds) {
} }
export function ServiceInfoPage() { export function ServiceInfoPage() {
const { tokens, refresh } = useAuth(); const { tokens, refresh, serviceInfo } = useAuth();
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -30,6 +30,10 @@ export function ServiceInfoPage() {
load().catch((e) => setError(String(e.message || e))); load().catch((e) => setError(String(e.message || e)));
}, []); }, []);
useEffect(() => {
if (serviceInfo) setInfo(serviceInfo);
}, [serviceInfo]);
const checkNow = async () => { const checkNow = async () => {
try { try {
setBusy(true); setBusy(true);
@@ -56,14 +60,28 @@ export function ServiceInfoPage() {
} }
return ( return (
<div> <div className="service-page">
<h2>Service Information</h2> <h2>Service Information</h2>
<p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p> <p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p>
{error && <div className="card error">{error}</div>} {error && <div className="card error">{error}</div>}
{message && <div className="test-connection-result ok">{message}</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="grid three">
<div className="card"> <div className="card service-card">
<h3>Application</h3> <h3>Application</h3>
<div className="overview-kv"> <div className="overview-kv">
<span>App Name</span> <span>App Name</span>
@@ -74,7 +92,7 @@ export function ServiceInfoPage() {
<strong>{info.api_prefix}</strong> <strong>{info.api_prefix}</strong>
</div> </div>
</div> </div>
<div className="card"> <div className="card service-card">
<h3>Runtime</h3> <h3>Runtime</h3>
<div className="overview-kv"> <div className="overview-kv">
<span>Host</span> <span>Host</span>
@@ -85,7 +103,7 @@ export function ServiceInfoPage() {
<strong>{formatUptime(info.uptime_seconds)}</strong> <strong>{formatUptime(info.uptime_seconds)}</strong>
</div> </div>
</div> </div>
<div className="card"> <div className="card service-card">
<h3>Version Status</h3> <h3>Version Status</h3>
<div className="overview-kv"> <div className="overview-kv">
<span>Current NexaPG Version</span> <span>Current NexaPG Version</span>
@@ -93,21 +111,16 @@ export function ServiceInfoPage() {
<span>Latest Known Version</span> <span>Latest Known Version</span>
<strong>{info.latest_version || "-"}</strong> <strong>{info.latest_version || "-"}</strong>
<span>Update Status</span> <span>Update Status</span>
<strong className={info.update_available ? "lag-bad" : "pill primary"}> <strong className={info.update_available ? "service-status-update" : "service-status-ok"}>
{info.update_available ? "Update available" : "Up to date"} {info.update_available ? "Update available" : "Up to date"}
</strong> </strong>
<span>Last Check</span> <span>Last Check</span>
<strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong> <strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong>
</div> </div>
<div className="form-actions" style={{ marginTop: 12 }}>
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
Check for Updates
</button>
</div>
</div> </div>
</div> </div>
<div className="card"> <div className="card service-card">
<h3>Release Source</h3> <h3>Release Source</h3>
<p className="muted"> <p className="muted">
Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed
@@ -121,7 +134,7 @@ export function ServiceInfoPage() {
</div> </div>
</div> </div>
<div className="card"> <div className="card service-card">
<h3>Version Control Policy</h3> <h3>Version Control Policy</h3>
<p className="muted"> <p className="muted">
Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG

View File

@@ -41,6 +41,19 @@ function formatNumber(value, digits = 2) {
return Number(value).toFixed(digits); return Number(value).toFixed(digits);
} }
function formatHostMetricUnavailable() {
return "N/A (agentless)";
}
function formatDiskSpaceAgentless(diskSpace) {
if (!diskSpace) return formatHostMetricUnavailable();
if (diskSpace.free_bytes !== null && diskSpace.free_bytes !== undefined) {
return formatBytes(diskSpace.free_bytes);
}
if (diskSpace.status === "unavailable") return formatHostMetricUnavailable();
return "-";
}
function MetricsTooltip({ active, payload, label }) { function MetricsTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null; if (!active || !payload || payload.length === 0) return null;
const row = payload[0]?.payload || {}; const row = payload[0]?.payload || {};
@@ -346,6 +359,9 @@ export function TargetDetailPage() {
{uiMode === "dba" && overview && ( {uiMode === "dba" && overview && (
<div className="card"> <div className="card">
<h3>Database Overview</h3> <h3>Database Overview</h3>
<p className="muted" style={{ marginTop: 2 }}>
Agentless mode: host-level CPU, RAM, and free-disk metrics are not available.
</p>
<div className="grid three overview-kv"> <div className="grid three overview-kv">
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div> <div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
<div> <div>
@@ -366,8 +382,8 @@ export function TargetDetailPage() {
<div title="Total WAL directory size (when available)"> <div title="Total WAL directory size (when available)">
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong> <span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
</div> </div>
<div title="Optional metric via future Agent/SSH provider"> <div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong> <span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
</div> </div>
<div title="Replication replay delay on standby"> <div title="Replication replay delay on standby">
<span>Replay Lag</span> <span>Replay Lag</span>
@@ -378,6 +394,12 @@ export function TargetDetailPage() {
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div> <div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div> <div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div> <div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
<div title="Host CPU requires OS-level telemetry">
<span>Host CPU</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
<div title="Host RAM requires OS-level telemetry">
<span>Host RAM</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
</div> </div>
<div className="grid two"> <div className="grid two">

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

@@ -29,6 +29,7 @@ export function AuthProvider({ children }) {
const [uiMode, setUiModeState] = useState(loadUiMode); const [uiMode, setUiModeState] = useState(loadUiMode);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 }); const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [alertToasts, setAlertToasts] = useState([]); const [alertToasts, setAlertToasts] = useState([]);
const [serviceInfo, setServiceInfo] = useState(null);
const knownAlertKeysRef = useRef(new Set()); const knownAlertKeysRef = useRef(new Set());
const hasAlertSnapshotRef = useRef(false); const hasAlertSnapshotRef = useRef(false);
@@ -175,6 +176,49 @@ export function AuthProvider({ children }) {
}; };
}, [tokens?.accessToken, tokens?.refreshToken]); }, [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 setUiMode = (nextMode) => {
const mode = nextMode === "easy" ? "easy" : "dba"; const mode = nextMode === "easy" ? "easy" : "dba";
setUiModeState(mode); setUiModeState(mode);
@@ -193,8 +237,10 @@ export function AuthProvider({ children }) {
alertStatus, alertStatus,
alertToasts, alertToasts,
dismissAlertToast, dismissAlertToast,
serviceInfo,
serviceUpdateAvailable: !!serviceInfo?.update_available,
}), }),
[tokens, me, uiMode, alertStatus, alertToasts] [tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
); );
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>; return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
} }

View File

@@ -114,6 +114,27 @@ a {
background: linear-gradient(180deg, #74e8ff, #25bdf3); background: linear-gradient(180deg, #74e8ff, #25bdf3);
} }
.nav-btn.update-available {
border-color: #c7962f;
background: linear-gradient(180deg, #3e2f14, #2f240f);
color: #ffecc4;
box-shadow: inset 0 0 0 1px #f6c75a38, 0 8px 20px #2d1d0680;
}
.nav-btn.update-available .nav-icon {
border-color: #d3a240;
background: linear-gradient(180deg, #5a441a, #433312);
}
.nav-btn.update-available:hover {
border-color: #ffd46e;
background: linear-gradient(180deg, #523d18, #3b2d12);
}
.nav-btn.update-available::before {
background: linear-gradient(180deg, #ffe4a3, #e0ac3e);
}
.nav-btn.admin-nav { .nav-btn.admin-nav {
border-color: #5b4da1; border-color: #5b4da1;
background: linear-gradient(180deg, #1c2a58, #18224a); background: linear-gradient(180deg, #1c2a58, #18224a);
@@ -174,6 +195,23 @@ a {
color: #d7e4fa; color: #d7e4fa;
} }
.profile-name {
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
.profile-email {
margin-top: 2px;
font-size: 12px;
color: #a6bcda;
word-break: break-all;
}
.profile-role {
margin-top: 4px;
}
.mode-switch-block { .mode-switch-block {
margin-bottom: 12px; margin-bottom: 12px;
padding: 10px; padding: 10px;
@@ -1094,6 +1132,39 @@ button {
border-color: #38bdf8; border-color: #38bdf8;
} }
.profile-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8px;
border: 1px solid #3a63a1;
border-radius: 10px;
background: linear-gradient(180deg, #15315d, #11274c);
color: #e7f2ff;
min-height: 40px;
font-weight: 650;
}
.profile-btn:hover {
border-color: #58b0e8;
background: linear-gradient(180deg, #1a427a, #15335f);
}
.profile-btn.active {
border-color: #66c7f4;
box-shadow: inset 0 0 0 1px #66c7f455;
}
.user-settings-page h2 {
margin-top: 4px;
margin-bottom: 4px;
}
.user-settings-card {
max-width: 760px;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -1201,6 +1272,31 @@ td {
font-weight: 600; font-weight: 600;
} }
.user-col-name-value {
font-weight: 600;
}
.admin-inline-grid {
display: grid;
gap: 8px;
}
.admin-inline-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-inline-password {
min-width: 190px;
}
.admin-user-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.role-pill { .role-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1279,6 +1375,51 @@ td {
color: #9eb8d6; color: #9eb8d6;
} }
.service-page .service-msg {
margin-bottom: 10px;
}
.service-hero {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.service-hero.ok {
border-color: #2f8f63;
background: linear-gradient(90deg, #123827, #102e42);
}
.service-hero.update {
border-color: #dfab3e;
background: linear-gradient(90deg, #4a3511, #2f2452);
box-shadow: 0 12px 28px #2b1f066b;
}
.service-hero-title {
display: inline-block;
font-size: 18px;
margin-bottom: 3px;
}
.service-hero-sub {
margin: 0;
}
.service-card {
box-shadow: 0 10px 24px #0416343d;
}
.service-status-ok {
color: #6ef0ad;
}
.service-status-update {
color: #ffd77e;
}
.alerts-subtitle { .alerts-subtitle {
margin-top: 2px; margin-top: 2px;
color: #a6c0df; color: #a6c0df;

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