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
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:
@@ -3,6 +3,8 @@
|
|||||||
# ------------------------------
|
# ------------------------------
|
||||||
# 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
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -14,6 +14,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
|
|||||||
- [Make Commands](#make-commands)
|
- [Make Commands](#make-commands)
|
||||||
- [Configuration Reference (`.env`)](#configuration-reference-env)
|
- [Configuration Reference (`.env`)](#configuration-reference-env)
|
||||||
- [Core Functional Areas](#core-functional-areas)
|
- [Core Functional Areas](#core-functional-areas)
|
||||||
|
- [Service Information](#service-information)
|
||||||
- [Target Owner Notifications](#target-owner-notifications)
|
- [Target Owner Notifications](#target-owner-notifications)
|
||||||
- [API Overview](#api-overview)
|
- [API Overview](#api-overview)
|
||||||
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
||||||
@@ -141,6 +142,7 @@ 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`) |
|
||||||
|
|
||||||
@@ -230,6 +232,13 @@ Recommended values for `VITE_API_URL`:
|
|||||||
- from email + from name
|
- from email + from name
|
||||||
- recipient test mail
|
- 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
|
## Target Owner Notifications
|
||||||
|
|
||||||
Email alert routing is target-specific:
|
Email alert routing is target-specific:
|
||||||
@@ -288,6 +297,11 @@ Email alert routing is target-specific:
|
|||||||
- `PUT /api/v1/admin/settings/email`
|
- `PUT /api/v1/admin/settings/email`
|
||||||
- `POST /api/v1/admin/settings/email/test`
|
- `POST /api/v1/admin/settings/email/test`
|
||||||
|
|
||||||
|
### Service Information
|
||||||
|
|
||||||
|
- `GET /api/v1/service/info`
|
||||||
|
- `POST /api/v1/service/info/check`
|
||||||
|
|
||||||
## `pg_stat_statements` Requirement
|
## `pg_stat_statements` Requirement
|
||||||
|
|
||||||
Query Insights requires `pg_stat_statements` on the monitored target:
|
Query Insights requires `pg_stat_statements` on the monitored target:
|
||||||
|
|||||||
34
backend/alembic/versions/0008_service_settings.py
Normal file
34
backend/alembic/versions/0008_service_settings.py
Normal 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")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
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 = APIRouter()
|
||||||
api_router.include_router(health.router, tags=["health"])
|
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(me.router, tags=["auth"])
|
||||||
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
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_users.router, prefix="/admin/users", tags=["admin"])
|
||||||
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])
|
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin"])
|
||||||
|
|||||||
105
backend/app/api/routes/service_info.py
Normal file
105
backend/app/api/routes/service_info.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -7,6 +7,7 @@ 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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.models import (
|
|||||||
EmailNotificationSettings,
|
EmailNotificationSettings,
|
||||||
Metric,
|
Metric,
|
||||||
QueryStat,
|
QueryStat,
|
||||||
|
ServiceInfoSettings,
|
||||||
Target,
|
Target,
|
||||||
TargetOwner,
|
TargetOwner,
|
||||||
User,
|
User,
|
||||||
@@ -15,6 +16,7 @@ __all__ = [
|
|||||||
"Target",
|
"Target",
|
||||||
"Metric",
|
"Metric",
|
||||||
"QueryStat",
|
"QueryStat",
|
||||||
|
"ServiceInfoSettings",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"AlertDefinition",
|
"AlertDefinition",
|
||||||
"EmailNotificationSettings",
|
"EmailNotificationSettings",
|
||||||
|
|||||||
@@ -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):
|
class AlertNotificationEvent(Base):
|
||||||
__tablename__ = "alert_notification_events"
|
__tablename__ = "alert_notification_events"
|
||||||
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
|
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
|
||||||
|
|||||||
29
backend/app/schemas/service_info.py
Normal file
29
backend/app/schemas/service_info.py
Normal 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
|
||||||
111
backend/app/services/service_info.py
Normal file
111
backend/app/services/service_info.py
Normal 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)
|
||||||
@@ -8,6 +8,7 @@ import { TargetDetailPage } from "./pages/TargetDetailPage";
|
|||||||
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
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";
|
||||||
|
|
||||||
function Protected({ children }) {
|
function Protected({ children }) {
|
||||||
const { tokens } = useAuth();
|
const { tokens } = useAuth();
|
||||||
@@ -61,6 +62,14 @@ 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}>
|
||||||
|
<span className="nav-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm0-11v6m0-10h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Service Information</span>
|
||||||
|
</NavLink>
|
||||||
{me?.role === "admin" && (
|
{me?.role === "admin" && (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-nav-spacer" aria-hidden="true" />
|
<div className="sidebar-nav-spacer" aria-hidden="true" />
|
||||||
@@ -150,6 +159,7 @@ export function App() {
|
|||||||
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
||||||
<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="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
133
frontend/src/pages/ServiceInfoPage.jsx
Normal file
133
frontend/src/pages/ServiceInfoPage.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
import { useAuth } from "../state";
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const total = Math.max(0, Number(seconds || 0));
|
||||||
|
const d = Math.floor(total / 86400);
|
||||||
|
const h = Math.floor((total % 86400) / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceInfoPage() {
|
||||||
|
const { tokens, refresh } = useAuth();
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setError("");
|
||||||
|
const data = await apiFetch("/service/info", {}, tokens, refresh);
|
||||||
|
setInfo(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch((e) => setError(String(e.message || e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkNow = async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
const result = await apiFetch("/service/info/check", { method: "POST" }, tokens, refresh);
|
||||||
|
await load();
|
||||||
|
if (result.last_check_error) {
|
||||||
|
setMessage(`Version check finished with warning: ${result.last_check_error}`);
|
||||||
|
} else if (result.update_available) {
|
||||||
|
setMessage(`Update available: ${result.latest_version}`);
|
||||||
|
} else {
|
||||||
|
setMessage("Version check completed. No update detected.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e.message || e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
return <div className="card">Loading service information...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Service Information</h2>
|
||||||
|
<p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p>
|
||||||
|
{error && <div className="card error">{error}</div>}
|
||||||
|
{message && <div className="test-connection-result ok">{message}</div>}
|
||||||
|
|
||||||
|
<div className="grid three">
|
||||||
|
<div className="card">
|
||||||
|
<h3>Application</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>App Name</span>
|
||||||
|
<strong>{info.app_name}</strong>
|
||||||
|
<span>Environment</span>
|
||||||
|
<strong>{info.environment}</strong>
|
||||||
|
<span>API Prefix</span>
|
||||||
|
<strong>{info.api_prefix}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Runtime</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Host</span>
|
||||||
|
<strong>{info.hostname}</strong>
|
||||||
|
<span>Python</span>
|
||||||
|
<strong>{info.python_version}</strong>
|
||||||
|
<span>Uptime</span>
|
||||||
|
<strong>{formatUptime(info.uptime_seconds)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Version Status</h3>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Current NexaPG Version</span>
|
||||||
|
<strong>{info.app_version}</strong>
|
||||||
|
<span>Latest Known Version</span>
|
||||||
|
<strong>{info.latest_version || "-"}</strong>
|
||||||
|
<span>Update Status</span>
|
||||||
|
<strong className={info.update_available ? "lag-bad" : "pill primary"}>
|
||||||
|
{info.update_available ? "Update available" : "Up to date"}
|
||||||
|
</strong>
|
||||||
|
<span>Last Check</span>
|
||||||
|
<strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions" style={{ marginTop: 12 }}>
|
||||||
|
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
|
||||||
|
Check for Updates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Release Source</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed
|
||||||
|
via UI.
|
||||||
|
</p>
|
||||||
|
<div className="overview-kv">
|
||||||
|
<span>Source Repository</span>
|
||||||
|
<strong>{info.update_source}</strong>
|
||||||
|
<span>Latest Reference Type</span>
|
||||||
|
<strong>{info.latest_ref || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Version Control Policy</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG
|
||||||
|
repository can change that behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user