Add service information feature with version checks
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s

This commit introduces a new "Service Information" section displaying runtime details, installed version, and update status for the NexaPG application. It includes backend API endpoints, database schema changes, and a corresponding frontend page that allows users to check for updates against the official repository. The `.env` example now includes an `APP_VERSION` variable, and related documentation has been updated.
This commit is contained in:
2026-02-13 08:54:13 +01:00
parent fd24a3a548
commit 0445a72764
12 changed files with 462 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
"""add service info settings
Revision ID: 0008_service_settings
Revises: 0007_email_templates
Create Date: 2026-02-13
"""
from alembic import op
import sqlalchemy as sa
revision = "0008_service_settings"
down_revision = "0007_email_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"service_info_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("current_version", sa.String(length=64), nullable=False, server_default="0.1.0"),
sa.Column("release_check_url", sa.String(length=500), nullable=True),
sa.Column("latest_version", sa.String(length=64), nullable=True),
sa.Column("update_available", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_check_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
def downgrade() -> None:
op.drop_table("service_info_settings")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from pydantic import BaseModel
class ServiceInfoOut(BaseModel):
app_name: str
environment: str
api_prefix: str
app_version: str
hostname: str
python_version: str
platform: str
service_started_at: datetime
uptime_seconds: int
update_source: str
latest_version: str | None
latest_ref: str | None
update_available: bool
last_checked_at: datetime | None
last_check_error: str | None
class ServiceInfoCheckResult(BaseModel):
latest_version: str | None
latest_ref: str | None
update_available: bool
last_checked_at: datetime
last_check_error: str | None

View File

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