feat: add public status page with component health monitoring and system metrics visualization

Add statuspage package with service, handler, and types for exposing platform health. Implement GET /api/v1/status endpoint returning operational status, component health (API, database, gateway runtime), and control plane summary counts.

Add Service.Snapshot method querying database connectivity, user/device/gateway/service/policy counts, connected device count via handshake timestamps, and gateway runtime tel
This commit is contained in:
2026-03-24 18:25:55 +01:00
parent 9aa4a13fd5
commit ff7eff8242
9 changed files with 541 additions and 1 deletions

View File

@@ -17,6 +17,7 @@ import (
"nexavpn/backend/internal/ipam"
"nexavpn/backend/internal/policy"
"nexavpn/backend/internal/servicecatalog"
"nexavpn/backend/internal/statuspage"
"nexavpn/backend/internal/user"
)
@@ -46,6 +47,7 @@ func New(cfg config.Config) (*App, error) {
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
auditService := audit.NewService(audit.NewPGRepository(pool))
statusService := statuspage.NewService(pool)
router := httpserver.NewRouter(cfg.JWTSecret, httpserver.Handlers{
Auth: auth.NewHandler(authService, auditService),
@@ -56,6 +58,7 @@ func New(cfg config.Config) (*App, error) {
Policy: policy.NewHandler(policyService, auditService),
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
Audit: audit.NewHandler(auditService),
Status: statuspage.NewHandler(statusService),
})
return &App{

View File

@@ -13,6 +13,7 @@ import (
"nexavpn/backend/internal/group"
"nexavpn/backend/internal/policy"
"nexavpn/backend/internal/servicecatalog"
"nexavpn/backend/internal/statuspage"
"nexavpn/backend/internal/user"
)
@@ -25,6 +26,7 @@ type Handlers struct {
Gateway *gateway.Handler
Group *group.Handler
Audit *audit.Handler
Status *statuspage.Handler
}
func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
@@ -36,6 +38,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
})
r.Route("/api/v1", func(r chi.Router) {
r.Get("/status", handlers.Status.PublicStatus)
r.Get("/auth/bootstrap/status", handlers.Auth.BootstrapStatus)
r.Post("/auth/bootstrap", handlers.Auth.Bootstrap)
r.Post("/auth/login", handlers.Auth.Login)

View File

@@ -0,0 +1,24 @@
package statuspage
import (
"net/http"
"nexavpn/backend/internal/apiutil"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) PublicStatus(w http.ResponseWriter, r *http.Request) {
snapshot := h.service.Snapshot(r.Context())
statusCode := http.StatusOK
if snapshot.Status != "operational" {
statusCode = http.StatusServiceUnavailable
}
apiutil.JSON(w, statusCode, snapshot)
}

View File

@@ -0,0 +1,155 @@
package statuspage
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Service struct {
db *pgxpool.Pool
}
func NewService(db *pgxpool.Pool) *Service {
return &Service{db: db}
}
func (s *Service) Snapshot(ctx context.Context) PublicStatusResponse {
now := time.Now().UTC()
resp := PublicStatusResponse{
Status: "operational",
GeneratedAt: now.Format(time.RFC3339),
Components: map[string]ComponentStatus{
"api": {
Status: "operational",
Message: "Public API is responding.",
},
},
}
if err := s.db.Ping(ctx); err != nil {
resp.Status = "degraded"
resp.Components["database"] = ComponentStatus{
Status: "degraded",
Message: "Database ping failed.",
}
return resp
}
resp.Components["database"] = ComponentStatus{
Status: "operational",
Message: "Database connectivity is healthy.",
}
if err := s.loadSummary(ctx, &resp.Summary); err != nil {
resp.Status = "degraded"
resp.Components["database"] = ComponentStatus{
Status: "degraded",
Message: "Database query failed.",
}
return resp
}
lastGatewayRuntime, err := s.latestGatewayRuntime(ctx)
if err != nil {
resp.Status = "degraded"
resp.Components["gateway"] = ComponentStatus{
Status: "degraded",
Message: "Gateway runtime state could not be read.",
}
return resp
}
resp.Components["gateway"] = componentFromGatewayRuntime(now, lastGatewayRuntime, resp.Summary.ActiveGateways)
if resp.Components["gateway"].Status != "operational" {
resp.Status = "degraded"
}
return resp
}
func (s *Service) loadSummary(ctx context.Context, summary *Summary) error {
return s.db.QueryRow(ctx, `
select
(select count(*) from users where deleted_at is null),
(select count(*) from devices where deleted_at is null),
(
select count(*)
from devices d
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
where d.deleted_at is null
and d.status = 'active'
and wp.latest_handshake_at is not null
and to_timestamp(wp.latest_handshake_at) >= now() - interval '3 minutes'
),
(select count(*) from gateways where deleted_at is null),
(select count(*) from gateways where deleted_at is null and is_active = true),
(select count(*) from services where deleted_at is null and is_active = true),
(select count(*) from policies where deleted_at is null and is_active = true)
`).Scan(
&summary.Users,
&summary.Devices,
&summary.ConnectedDevices,
&summary.Gateways,
&summary.ActiveGateways,
&summary.Services,
&summary.Policies,
)
}
func (s *Service) latestGatewayRuntime(ctx context.Context) (*time.Time, error) {
var updatedAt *time.Time
err := s.db.QueryRow(ctx, `
select max(updated_at)
from settings
where category = 'gateway_runtime'
`).Scan(&updatedAt)
if err != nil {
return nil, err
}
return updatedAt, nil
}
func componentFromGatewayRuntime(now time.Time, lastRuntime *time.Time, activeGateways int) ComponentStatus {
if activeGateways == 0 {
return ComponentStatus{
Status: "degraded",
Message: "No active gateway is configured.",
}
}
if lastRuntime == nil {
return ComponentStatus{
Status: "degraded",
Message: "No gateway telemetry has been received yet.",
}
}
age := now.Sub(lastRuntime.UTC())
if age <= 90*time.Second {
return ComponentStatus{
Status: "operational",
Message: fmt.Sprintf("Last gateway telemetry %s ago.", humanizeAge(age)),
}
}
return ComponentStatus{
Status: "degraded",
Message: fmt.Sprintf("Gateway telemetry is stale (%s ago).", humanizeAge(age)),
}
}
func humanizeAge(age time.Duration) string {
if age < time.Minute {
seconds := int(age.Seconds())
if seconds < 1 {
seconds = 1
}
return fmt.Sprintf("%ds", seconds)
}
if age < time.Hour {
return fmt.Sprintf("%dm", int(age.Minutes()))
}
return fmt.Sprintf("%dh", int(age.Hours()))
}

View File

@@ -0,0 +1,23 @@
package statuspage
type ComponentStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type Summary struct {
Users int `json:"users"`
Devices int `json:"devices"`
ConnectedDevices int `json:"connected_devices"`
Gateways int `json:"gateways"`
ActiveGateways int `json:"active_gateways"`
Services int `json:"services"`
Policies int `json:"policies"`
}
type PublicStatusResponse struct {
Status string `json:"status"`
GeneratedAt string `json:"generated_at"`
Components map[string]ComponentStatus `json:"components"`
Summary Summary `json:"summary"`
}

View File

@@ -17,7 +17,8 @@
Use the desktop client to sign in, provision this device, and connect to your private network.
</p>
<div class="actions">
<a class="button primary" href="/api/v1/healthz">API health</a>
<a class="button primary" href="/status">System status</a>
<a class="button secondary" href="/api/v1/status">Raw API status</a>
</div>
</section>

View File

@@ -12,6 +12,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /status {
try_files /status.html =404;
}
location / {
try_files $uri /index.html;
}

171
public-web/status.html Normal file
View File

@@ -0,0 +1,171 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaVPN Status</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell status-shell">
<section class="hero status-hero">
<img class="logo" src="/NexaVPN_Logo.png" alt="NexaVPN" />
<p class="eyebrow">Public status</p>
<div class="status-hero-head">
<div>
<h1>Platform status</h1>
<p class="copy">Live health for the public API, database connectivity, and gateway runtime telemetry.</p>
</div>
<div id="status-pill" class="status-pill is-loading">Checking</div>
</div>
<div class="actions">
<a class="button secondary" href="/">Back to landing page</a>
<a class="button secondary" href="/api/v1/status">Open raw JSON</a>
</div>
</section>
<section class="status-grid">
<section class="card">
<div class="section-head">
<div>
<p class="eyebrow">Components</p>
<h2>Service health</h2>
</div>
<p id="generated-at" class="meta">Waiting for first update</p>
</div>
<div id="component-list" class="component-list"></div>
</section>
<section class="card">
<div class="section-head">
<div>
<p class="eyebrow">Estate</p>
<h2>Control plane snapshot</h2>
</div>
<p class="meta">Counts from the live NexaVPN backend.</p>
</div>
<div id="summary-grid" class="summary-grid"></div>
</section>
</section>
</main>
<script>
const componentList = document.getElementById("component-list");
const summaryGrid = document.getElementById("summary-grid");
const generatedAt = document.getElementById("generated-at");
const statusPill = document.getElementById("status-pill");
const componentLabels = {
api: "Public API",
database: "Database",
gateway: "Gateway runtime",
};
const summaryLabels = {
users: "Users",
devices: "Devices",
connected_devices: "Connected devices",
gateways: "Gateways",
active_gateways: "Active gateways",
services: "Published services",
policies: "Active policies",
};
function titleForComponent(key) {
return componentLabels[key] || key;
}
function titleForSummary(key) {
return summaryLabels[key] || key;
}
function setOverallStatus(status) {
statusPill.className = "status-pill";
if (status === "operational") {
statusPill.classList.add("is-operational");
statusPill.textContent = "Operational";
return;
}
statusPill.classList.add("is-degraded");
statusPill.textContent = "Degraded";
}
function renderComponents(components) {
componentList.innerHTML = "";
Object.entries(components || {}).forEach(([key, component]) => {
const item = document.createElement("article");
item.className = "component-item";
const badge = document.createElement("span");
badge.className = `component-badge is-${component.status || "degraded"}`;
badge.textContent = component.status || "unknown";
const body = document.createElement("div");
body.className = "component-body";
const heading = document.createElement("div");
heading.className = "component-heading";
heading.textContent = titleForComponent(key);
const message = document.createElement("p");
message.className = "component-message";
message.textContent = component.message || "No additional details.";
body.appendChild(heading);
body.appendChild(message);
item.appendChild(body);
item.appendChild(badge);
componentList.appendChild(item);
});
}
function renderSummary(summary) {
summaryGrid.innerHTML = "";
Object.entries(summary || {}).forEach(([key, value]) => {
const tile = document.createElement("article");
tile.className = "summary-tile";
const label = document.createElement("p");
label.className = "summary-label";
label.textContent = titleForSummary(key);
const metric = document.createElement("strong");
metric.className = "summary-value";
metric.textContent = value;
tile.appendChild(label);
tile.appendChild(metric);
summaryGrid.appendChild(tile);
});
}
async function refreshStatus() {
try {
const response = await fetch("/api/v1/status", { cache: "no-store" });
const payload = await response.json();
setOverallStatus(payload.status);
renderComponents(payload.components);
renderSummary(payload.summary);
generatedAt.textContent = `Updated ${new Date(payload.generated_at).toLocaleString()}`;
} catch (_error) {
setOverallStatus("degraded");
componentList.innerHTML = `
<article class="component-item">
<div class="component-body">
<div class="component-heading">Status page</div>
<p class="component-message">The public status endpoint could not be reached.</p>
</div>
<span class="component-badge is-degraded">degraded</span>
</article>
`;
summaryGrid.innerHTML = "";
generatedAt.textContent = "Update failed";
}
}
refreshStatus();
window.setInterval(refreshStatus, 15000);
</script>
</body>
</html>

View File

@@ -118,9 +118,165 @@ h1 {
line-height: 1.8;
}
.status-shell {
width: min(1120px, 100%);
}
.status-hero {
gap: 22px;
}
.status-hero-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 144px;
padding: 14px 20px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font-weight: 800;
}
.status-pill.is-loading {
color: var(--muted);
}
.status-pill.is-operational {
background: linear-gradient(135deg, rgba(116, 224, 184, 0.2), rgba(31, 182, 122, 0.18));
border-color: rgba(116, 224, 184, 0.28);
color: var(--accent);
}
.status-pill.is-degraded {
background: linear-gradient(135deg, rgba(255, 164, 121, 0.18), rgba(255, 96, 96, 0.12));
border-color: rgba(255, 140, 140, 0.24);
color: #ffc0c0;
}
.status-grid {
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 22px;
}
.section-head h2 {
font-size: 1.45rem;
margin-top: 8px;
}
.meta {
color: var(--muted);
font-size: 0.95rem;
}
.component-list,
.summary-grid {
display: grid;
gap: 14px;
}
.component-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 20px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.component-body {
display: grid;
gap: 8px;
}
.component-heading {
font-size: 1.05rem;
font-weight: 700;
}
.component-message {
color: var(--muted);
line-height: 1.55;
}
.component-badge {
flex-shrink: 0;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.component-badge.is-operational {
color: var(--accent);
border-color: rgba(116, 224, 184, 0.22);
background: rgba(116, 224, 184, 0.08);
}
.component-badge.is-degraded {
color: #ffc0c0;
border-color: rgba(255, 140, 140, 0.22);
background: rgba(255, 128, 128, 0.08);
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-tile {
display: grid;
gap: 10px;
padding: 18px 20px;
border-radius: 20px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.summary-label {
color: var(--muted);
font-size: 0.92rem;
}
.summary-value {
font-size: 2rem;
line-height: 1;
}
@media (max-width: 720px) {
.hero,
.card {
padding: 24px;
}
.status-hero-head,
.section-head {
flex-direction: column;
}
.status-grid,
.summary-grid {
grid-template-columns: 1fr;
}
}