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
+
Runtime details, installed version, and update check status for this NexaPG instance.
+ {error &&+ Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed + via UI. +
++ Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG + repository can change that behavior. +
+