From ff7eff8242648fa877cbbef9862ecd0fe65cc8f4 Mon Sep 17 00:00:00 2001
From: nessi
Date: Tue, 24 Mar 2026 18:25:55 +0100
Subject: [PATCH] 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
---
backend/internal/app/app.go | 3 +
backend/internal/httpserver/router.go | 3 +
backend/internal/statuspage/handler.go | 24 ++++
backend/internal/statuspage/service.go | 155 ++++++++++++++++++++++
backend/internal/statuspage/types.go | 23 ++++
public-web/index.html | 3 +-
public-web/nginx.conf | 4 +
public-web/status.html | 171 +++++++++++++++++++++++++
public-web/styles.css | 156 ++++++++++++++++++++++
9 files changed, 541 insertions(+), 1 deletion(-)
create mode 100644 backend/internal/statuspage/handler.go
create mode 100644 backend/internal/statuspage/service.go
create mode 100644 backend/internal/statuspage/types.go
create mode 100644 public-web/status.html
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index e32fd3a..5d6a83a 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -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{
diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go
index 1ea5444..17e590f 100644
--- a/backend/internal/httpserver/router.go
+++ b/backend/internal/httpserver/router.go
@@ -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)
diff --git a/backend/internal/statuspage/handler.go b/backend/internal/statuspage/handler.go
new file mode 100644
index 0000000..4d05b9d
--- /dev/null
+++ b/backend/internal/statuspage/handler.go
@@ -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)
+}
diff --git a/backend/internal/statuspage/service.go b/backend/internal/statuspage/service.go
new file mode 100644
index 0000000..d5127ec
--- /dev/null
+++ b/backend/internal/statuspage/service.go
@@ -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()))
+}
diff --git a/backend/internal/statuspage/types.go b/backend/internal/statuspage/types.go
new file mode 100644
index 0000000..9bcd265
--- /dev/null
+++ b/backend/internal/statuspage/types.go
@@ -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"`
+}
diff --git a/public-web/index.html b/public-web/index.html
index 9d67c2f..54a225b 100644
--- a/public-web/index.html
+++ b/public-web/index.html
@@ -17,7 +17,8 @@
Use the desktop client to sign in, provision this device, and connect to your private network.
diff --git a/public-web/nginx.conf b/public-web/nginx.conf
index 31e6d87..260b7c2 100644
--- a/public-web/nginx.conf
+++ b/public-web/nginx.conf
@@ -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;
}
diff --git a/public-web/status.html b/public-web/status.html
new file mode 100644
index 0000000..86d958c
--- /dev/null
+++ b/public-web/status.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+ NexaVPN Status
+
+
+
+
+
+
+
+ Public status
+
+
+
Platform status
+
Live health for the public API, database connectivity, and gateway runtime telemetry.
+
+
Checking
+
+
+
+
+
+
+
+
+
Components
+
Service health
+
+
Waiting for first update
+
+
+
+
+
+
+
+
Estate
+
Control plane snapshot
+
+
Counts from the live NexaVPN backend.
+
+
+
+
+
+
+
+
+
diff --git a/public-web/styles.css b/public-web/styles.css
index 4e8c343..169c0a3 100644
--- a/public-web/styles.css
+++ b/public-web/styles.css
@@ -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;
+ }
}