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

View File

@@ -13,6 +13,7 @@ import (
"nexavpn/backend/internal/group" "nexavpn/backend/internal/group"
"nexavpn/backend/internal/policy" "nexavpn/backend/internal/policy"
"nexavpn/backend/internal/servicecatalog" "nexavpn/backend/internal/servicecatalog"
"nexavpn/backend/internal/statuspage"
"nexavpn/backend/internal/user" "nexavpn/backend/internal/user"
) )
@@ -25,6 +26,7 @@ type Handlers struct {
Gateway *gateway.Handler Gateway *gateway.Handler
Group *group.Handler Group *group.Handler
Audit *audit.Handler Audit *audit.Handler
Status *statuspage.Handler
} }
func NewRouter(jwtSecret string, handlers Handlers) http.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.Route("/api/v1", func(r chi.Router) {
r.Get("/status", handlers.Status.PublicStatus)
r.Get("/auth/bootstrap/status", handlers.Auth.BootstrapStatus) r.Get("/auth/bootstrap/status", handlers.Auth.BootstrapStatus)
r.Post("/auth/bootstrap", handlers.Auth.Bootstrap) r.Post("/auth/bootstrap", handlers.Auth.Bootstrap)
r.Post("/auth/login", handlers.Auth.Login) 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. Use the desktop client to sign in, provision this device, and connect to your private network.
</p> </p>
<div class="actions"> <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> </div>
</section> </section>

View File

@@ -12,6 +12,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /status {
try_files /status.html =404;
}
location / { location / {
try_files $uri /index.html; 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; 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) { @media (max-width: 720px) {
.hero, .hero,
.card { .card {
padding: 24px; padding: 24px;
} }
.status-hero-head,
.section-head {
flex-direction: column;
}
.status-grid,
.summary-grid {
grid-template-columns: 1fr;
}
} }