diff --git a/.env.example b/.env.example index 802baf1..8c1d178 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ # ------------------------------ # Display name used in API docs/UI. APP_NAME=NexaPG Monitor +# Manual version string shown in Service Information page. +APP_VERSION=0.1.0 # Runtime environment: dev | staging | prod | test ENVIRONMENT=dev # Backend log level: DEBUG | INFO | WARNING | ERROR diff --git a/README.md b/README.md index 92ec134..fe1b42f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, - [Make Commands](#make-commands) - [Configuration Reference (`.env`)](#configuration-reference-env) - [Core Functional Areas](#core-functional-areas) +- [Service Information](#service-information) - [Target Owner Notifications](#target-owner-notifications) - [API Overview](#api-overview) - [`pg_stat_statements` Requirement](#pg_stat_statements-requirement) @@ -141,6 +142,7 @@ Note: Migrations run automatically when the backend container starts (`entrypoin | Variable | Description | |---|---| | `APP_NAME` | Application display name | +| `APP_VERSION` | Displayed NexaPG version in Service Information | | `ENVIRONMENT` | Runtime environment (`dev`, `staging`, `prod`, `test`) | | `LOG_LEVEL` | Backend log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | @@ -230,6 +232,13 @@ Recommended values for `VITE_API_URL`: - 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 official upstream repository (`git.nesterovic.cc/nessi/NexaPG`) +- Version/update source are read-only in UI (maintainer-controlled in code/release flow) + ## Target Owner Notifications Email alert routing is target-specific: @@ -288,6 +297,11 @@ Email alert routing is target-specific: - `PUT /api/v1/admin/settings/email` - `POST /api/v1/admin/settings/email/test` +### Service Information + +- `GET /api/v1/service/info` +- `POST /api/v1/service/info/check` + ## `pg_stat_statements` Requirement Query Insights requires `pg_stat_statements` on the monitored target: diff --git a/backend/alembic/versions/0008_service_settings.py b/backend/alembic/versions/0008_service_settings.py new file mode 100644 index 0000000..a1d2362 --- /dev/null +++ b/backend/alembic/versions/0008_service_settings.py @@ -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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d436c93..43e9f1d 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.routes import admin_settings, admin_users, alerts, 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"]) @@ -7,5 +7,6 @@ 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"]) diff --git a/backend/app/api/routes/service_info.py b/backend/app/api/routes/service_info.py new file mode 100644 index 0000000..f33a5b4 --- /dev/null +++ b/backend/app/api/routes/service_info.py @@ -0,0 +1,105 @@ +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.audit import write_audit_log +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: + 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) + 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( + 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, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 21cc3ec..6c1c2bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,6 +7,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") app_name: str = "NexaPG Monitor" + app_version: str = "0.1.0" environment: str = "dev" api_v1_prefix: str = "/api/v1" log_level: str = "INFO" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d9ac4cd..516595b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.models import ( EmailNotificationSettings, Metric, QueryStat, + ServiceInfoSettings, Target, TargetOwner, User, @@ -15,6 +16,7 @@ __all__ = [ "Target", "Metric", "QueryStat", + "ServiceInfoSettings", "AuditLog", "AlertDefinition", "EmailNotificationSettings", diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 47ee6a7..e3507eb 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -147,6 +147,25 @@ class EmailNotificationSettings(Base): ) +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"),) diff --git a/backend/app/schemas/service_info.py b/backend/app/schemas/service_info.py new file mode 100644 index 0000000..cd8171e --- /dev/null +++ b/backend/app/schemas/service_info.py @@ -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 diff --git a/backend/app/services/service_info.py b/backend/app/services/service_info.py new file mode 100644 index 0000000..b19a10f --- /dev/null +++ b/backend/app/services/service_info.py @@ -0,0 +1,111 @@ +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" + 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: + 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: + pass + + 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]: + 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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b730b9..2471ca5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ 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"; function Protected({ children }) { const { tokens } = useAuth(); @@ -61,6 +62,14 @@ function Layout({ children }) { Alerts + + + Service Information + {me?.role === "admin" && ( <>