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.

- API health + System status + Raw API status
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; + } }