Преглед на файлове

Backend: SQLite, JWT auth, SSE live feed, metrics, alerts, consumer

Splits the monolithic main.go into focused files and adds the data
plane that the dashboard needs:

server/store.go     - SQLite (modernc.org/sqlite, pure Go, no cgo)
                       tables: users, events, commands, alerts
                       helpers: save/list events, save/update commands,
                       create/list/ack alerts
server/auth.go      - JWT (HS256, 24h) with roles
                       roles: system_admin / project_admin / user
                       scrypt password hashing
                       login + me + role-required middleware
server/metrics.go   - 1-minute buckets, 24h retention
                       events_total, by_type, commands_total/ok/fail
server/sse.go       - Server-Sent Events broadcaster
                       /api/events/stream with optional router_id /
                       event_type filter, 15s keepalive pings
server/consumer.go  - Redpanda consumer (group client2server-persistor)
                       writes router-events to SQLite for history
server/main.go      - rewired: ingestEvent() called from WS + HTTP,
                       publish to Redpanda, persist to SQLite, broadcast
                       via SSE hub, record metrics

New endpoints (all on :3843):
  POST /api/auth/login           - {username,password} -> {token,role}
  GET  /api/auth/me              - current user (JWT)
  GET  /api/events/list          - historical events (auth)
  GET  /api/commands             - command history (auth)
  GET  /api/metrics?since=1h     - time-series metrics (auth)
  GET  /api/alerts?unack=1       - alerts (auth)
  POST /api/alerts/{id}/ack      - acknowledge (auth)
  GET  /api/events/stream        - SSE live feed (JWT or legacy token)

Background jobs:
  - 1-min janitor: expires idempotency cache
  - 30s offline watcher: creates 'router_offline' alerts
  - consumer: persists router-events to SQLite for queries

Default admin user: username=admin, password=admin (CHANGE IN PROD)

Smoke tested:
  - login (admin/admin -> JWT, 200; wrong pw -> 401)
  - /api/auth/me with JWT
  - ingest event via HTTP, see in /api/events/list
  - metrics reflect the event
  - offline command queues, persists to /api/commands
  - SSE delivers 5/5 events with keepalive ping
Gogs преди 2 месеца
родител
ревизия
6af966de3a
променени са 9 файла, в които са добавени 1338 реда и са изтрити 141 реда
  1. 7 5
      .gitignore
  2. 277 0
      server/auth.go
  3. 65 0
      server/consumer.go
  4. 15 2
      server/go.mod
  5. 49 2
      server/go.sum
  6. 380 132
      server/main.go
  7. 117 0
      server/metrics.go
  8. 147 0
      server/sse.go
  9. 281 0
      server/store.go

+ 7 - 5
.gitignore

@@ -1,6 +1,8 @@
-.DS_Store
+dashboard/node_modules/
+dashboard/dist/
+dashboard/.vite/
+server/data/
+server/*.db
+server/*.db-journal
 *.swp
-*.swo
-*~
-.env
-node_modules/
+.DS_Store

+ 277 - 0
server/auth.go

@@ -0,0 +1,277 @@
+// client2server - Authentication (JWT) and password hashing
+//
+// Roles: system_admin, project_admin, user
+// Tokens are HMAC-SHA256, 24h expiry.
+
+package main
+
+import (
+	"crypto/hmac"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"golang.org/x/crypto/scrypt"
+)
+
+const (
+	RoleSystemAdmin  = "system_admin"
+	RoleProjectAdmin = "project_admin"
+	RoleUser         = "user"
+
+	tokenLifetime = 24 * time.Hour
+	scryptN       = 1 << 15
+	scryptR       = 8
+	scryptP       = 1
+	scryptKeyLen  = 32
+)
+
+var (
+	jwtSecretMu sync.RWMutex
+	jwtSecret   []byte
+)
+
+func getJWTSecret() []byte {
+	jwtSecretMu.RLock()
+	defer jwtSecretMu.RUnlock()
+	if len(jwtSecret) > 0 {
+		return jwtSecret
+	}
+	// Default to env, or a deterministic dev key
+	s := os.Getenv("JWT_SECRET")
+	if s == "" {
+		s = "dev-insecure-jwt-secret-change-me-please-32b"
+	}
+	return []byte(s)
+}
+
+func setJWTSecret(s []byte) {
+	jwtSecretMu.Lock()
+	defer jwtSecretMu.Unlock()
+	jwtSecret = s
+}
+
+// ----------------------------------------------------------------------------
+// Password hashing (scrypt)
+// ----------------------------------------------------------------------------
+
+func HashPassword(password string) (string, error) {
+	salt := make([]byte, 16)
+	if _, err := rand.Read(salt); err != nil {
+		return "", err
+	}
+	h, err := scryptHash([]byte(password), salt)
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("scrypt$%s$%s",
+		base64.RawStdEncoding.EncodeToString(salt),
+		base64.RawStdEncoding.EncodeToString(h),
+	), nil
+}
+
+func VerifyPassword(password, encoded string) (bool, error) {
+	parts := strings.Split(encoded, "$")
+	if len(parts) != 3 || parts[0] != "scrypt" {
+		return false, errors.New("invalid hash format")
+	}
+	salt, err := base64.RawStdEncoding.DecodeString(parts[1])
+	if err != nil {
+		return false, err
+	}
+	want, err := base64.RawStdEncoding.DecodeString(parts[2])
+	if err != nil {
+		return false, err
+	}
+	got, err := scryptHash([]byte(password), salt)
+	if err != nil {
+		return false, err
+	}
+	return hmac.Equal(got, want), nil
+}
+
+func scryptHash(password, salt []byte) ([]byte, error) {
+	return scryptKey(password, salt, scryptN, scryptR, scryptP, scryptKeyLen)
+}
+
+// scryptKey is a small wrapper that uses stdlib crypto/scrypt via a pure-Go
+// implementation. We import golang.org/x/crypto/scrypt for the real one.
+//
+// (Note: scrypt is a real stdlib-ish dep. The 'scrypt' symbol below is
+// provided by golang.org/x/crypto/scrypt, imported separately as needed.)
+func scryptKey(pw, salt []byte, n, r, p, keyLen int) ([]byte, error) {
+	return scrypt.Key(pw, salt, n, r, p, keyLen)
+}
+
+// ----------------------------------------------------------------------------
+// JWT
+// ----------------------------------------------------------------------------
+
+type Claims struct {
+	UserID   int64  `json:"uid"`
+	Username string `json:"usr"`
+	Role     string `json:"rol"`
+	jwt.RegisteredClaims
+}
+
+func IssueJWT(userID int64, username, role string) (string, error) {
+	now := time.Now()
+	claims := Claims{
+		UserID:   userID,
+		Username: username,
+		Role:     role,
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:    "client2server",
+			Subject:   username,
+			IssuedAt:  jwt.NewNumericDate(now),
+			ExpiresAt: jwt.NewNumericDate(now.Add(tokenLifetime)),
+		},
+	}
+	tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	return tok.SignedString(getJWTSecret())
+}
+
+func ParseJWT(tokenStr string) (*Claims, error) {
+	claims := &Claims{}
+	tok, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
+		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+		}
+		return getJWTSecret(), nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if !tok.Valid {
+		return nil, errors.New("invalid token")
+	}
+	return claims, nil
+}
+
+// ----------------------------------------------------------------------------
+// HTTP middleware
+// ----------------------------------------------------------------------------
+
+type ctxKey int
+
+const ctxClaimsKey ctxKey = 0
+
+func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		auth := r.Header.Get("Authorization")
+		token := strings.TrimPrefix(auth, "Bearer ")
+		if token == "" || token == auth {
+			http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized)
+			return
+		}
+		claims, err := ParseJWT(token)
+		if err != nil {
+			http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
+			return
+		}
+		next(w, r.WithContext(r.Context()))
+		_ = claims
+	}
+}
+
+func requireRole(roles ...string) func(http.HandlerFunc) http.HandlerFunc {
+	return func(next http.HandlerFunc) http.HandlerFunc {
+		return authMiddleware(func(w http.ResponseWriter, r *http.Request) {
+			auth := r.Header.Get("Authorization")
+			token := strings.TrimPrefix(auth, "Bearer ")
+			claims, err := ParseJWT(token)
+			if err != nil {
+				http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
+				return
+			}
+			for _, want := range roles {
+				if claims.Role == want {
+					next(w, r)
+					return
+				}
+			}
+			http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
+		})
+	}
+}
+
+// ----------------------------------------------------------------------------
+// Login endpoint
+// ----------------------------------------------------------------------------
+
+type loginRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+type loginResponse struct {
+	Token   string `json:"token"`
+	Role    string `json:"role"`
+	Expires string `json:"expires"`
+}
+
+func handleLogin(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+		return
+	}
+	var req loginRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
+		return
+	}
+	if req.Username == "" || req.Password == "" {
+		http.Error(w, `{"error":"username and password required"}`, http.StatusBadRequest)
+		return
+	}
+
+	var (
+		id   int64
+		hash string
+		role string
+	)
+	err := db.QueryRow("SELECT id, password_hash, role FROM users WHERE username = ?", req.Username).Scan(&id, &hash, &role)
+	if err != nil {
+		http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
+		return
+	}
+	ok, err := VerifyPassword(req.Password, hash)
+	if err != nil || !ok {
+		http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
+		return
+	}
+
+	tok, err := IssueJWT(id, req.Username, role)
+	if err != nil {
+		http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(loginResponse{
+		Token:   tok,
+		Role:    role,
+		Expires: time.Now().Add(tokenLifetime).Format(time.RFC3339),
+	})
+}
+
+// ----------------------------------------------------------------------------
+// Random hex (for default admin token in logs)
+// ----------------------------------------------------------------------------
+
+func randHex(n int) string {
+	b := make([]byte, n)
+	_, _ = rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+

+ 65 - 0
server/consumer.go

@@ -0,0 +1,65 @@
+// client2server - Redpanda consumer
+//
+// Reads router-events from Redpanda and writes to SQLite for historical
+// queries. Started in background.
+
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"log"
+	"time"
+
+	"github.com/twmb/franz-go/pkg/kgo"
+)
+
+func startConsumer(ctx context.Context) {
+	if kcl == nil {
+		return
+	}
+	cl, err := kgo.NewClient(
+		kgo.SeedBrokers(cfg.RedpandaBrokers...),
+		kgo.ConsumerGroup("client2server-persistor"),
+		kgo.ConsumeTopics("router-events"),
+		kgo.DisableAutoCommit(),
+	)
+	if err != nil {
+		log.Printf("consumer: %v", err)
+		return
+	}
+	defer cl.Close()
+
+	log.Println("consumer: started, reading router-events")
+
+	for {
+		if ctx.Err() != nil {
+			return
+		}
+		fetches := cl.PollFetches(ctx)
+		if errs := fetches.Errors(); len(errs) > 0 {
+			for _, e := range errs {
+				log.Printf("consumer poll: %v", e.Err)
+			}
+		}
+		fetches.EachRecord(func(rec *kgo.Record) {
+			var ev RouterEvent
+			if err := json.Unmarshal(rec.Value, &ev); err != nil {
+				log.Printf("consumer decode: %v", err)
+				return
+			}
+			// Ensure timestamps
+			if ev.ReceivedAt.IsZero() {
+				ev.ReceivedAt = rec.Timestamp
+			}
+			if err := saveEvent(ev); err != nil {
+				log.Printf("consumer save: %v", err)
+			}
+		})
+		// Best-effort commit
+		if err := cl.CommitUncommittedOffsets(ctx); err != nil {
+			log.Printf("consumer commit: %v", err)
+		}
+		_ = time.Second
+	}
+}

+ 15 - 2
server/go.mod

@@ -4,14 +4,27 @@ go 1.22
 
 require (
 	github.com/coder/websocket v1.8.13
+	github.com/golang-jwt/jwt/v5 v5.2.1
 	github.com/google/uuid v1.6.0
 	github.com/twmb/franz-go v1.18.0
-	github.com/twmb/franz-go/pkg/kadm v1.14.0
+	golang.org/x/crypto v0.28.0
+	modernc.org/sqlite v1.34.1
 )
 
 require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/klauspost/compress v1.17.11 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
 	github.com/pierrec/lz4/v4 v4.1.21 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/twmb/franz-go/pkg/kmsg v1.9.0 // indirect
-	golang.org/x/crypto v0.28.0 // indirect
+	golang.org/x/sys v0.26.0 // indirect
+	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+	modernc.org/libc v1.55.3 // indirect
+	modernc.org/mathutil v1.6.0 // indirect
+	modernc.org/memory v1.8.0 // indirect
+	modernc.org/strutil v1.2.0 // indirect
+	modernc.org/token v1.1.0 // indirect
 )

+ 49 - 2
server/go.sum

@@ -1,16 +1,63 @@
 github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
 github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
 github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
 github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/twmb/franz-go v1.18.0 h1:25FjMZfdozBywVX+5xrWC2W+W76i0xykKjTdEeD2ejw=
 github.com/twmb/franz-go v1.18.0/go.mod h1:zXCGy74M0p5FbXsLeASdyvfLFsBvTubVqctIaa5wQ+I=
-github.com/twmb/franz-go/pkg/kadm v1.14.0 h1:nAn1co1lXzJQocpzyIyOFOjUBf4WHWs5/fTprXy2IZs=
-github.com/twmb/franz-go/pkg/kadm v1.14.0/go.mod h1:XjOPz6ZaXXjrW2jVCfLuucP8H1w2TvD6y3PT2M+aAM4=
 github.com/twmb/franz-go/pkg/kmsg v1.9.0 h1:JojYUph2TKAau6SBtErXpXGC7E3gg4vGZMv9xFU/B6M=
 github.com/twmb/franz-go/pkg/kmsg v1.9.0/go.mod h1:CMbfazviCyY6HM0SXuG5t9vOwYDHRCSrJJyBAe5paqg=
 golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
 golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
+modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 380 - 132
server/main.go

@@ -1,12 +1,29 @@
-// client2server - Go server with WebSocket + Redpanda (Kafka-compatible)
+// client2server - Go server with WebSocket + Redpanda + Dashboard API
 // Copyright (c) 2026 Luis Rosales - MIT License
 //
-// Build: go build -o client2server-server .
-// Run:   ./client2server-server
-// Env:   REDPANDA_BROKERS=localhost:9092 TOKEN=*** PORT=3843
+// Stack:
+//   - WebSocket: github.com/coder/websocket
+//   - Kafka: github.com/twmb/franz-go (talks to Redpanda)
+//   - Auth: JWT (HS256) with role-based access
+//   - Storage: SQLite (modernc.org/sqlite, pure Go, no cgo)
+//   - Live feed: Server-Sent Events
 //
-// WebSocket: ws://localhost:3843/ws
-// HTTP API:  http://localhost:3843/api/{events,routers,command}, /health
+// Endpoints:
+//   POST /api/auth/login                  - username/password -> JWT
+//   GET  /api/auth/me                     - current user
+//   GET  /health                          - liveness
+//   GET  /api/routers                     - known routers (legacy shared token OK)
+//   GET  /api/events?limit=&router_id=    - event history (auth)
+//   POST /api/events                      - ingest event (router or hotplug)
+//   POST /api/command                     - send command to router (auth)
+//   GET  /api/commands?router_id=         - command history
+//   GET  /api/metrics?since=1h            - time-series metrics
+//   GET  /api/alerts?unack=1              - alerts
+//   POST /api/alerts/:id/ack              - acknowledge alert
+//   GET  /api/events/stream               - SSE live feed
+//   GET  /ws                              - WebSocket from routers (legacy token)
+//
+// Env: REDPANDA_BROKERS, TOKEN (legacy shared), JWT_SECRET, DB_PATH, PORT
 
 package main
 
@@ -19,6 +36,7 @@ import (
 	"net/http"
 	"os"
 	"os/signal"
+	"strconv"
 	"strings"
 	"sync"
 	"syscall"
@@ -26,8 +44,6 @@ import (
 
 	"github.com/coder/websocket"
 	"github.com/google/uuid"
-	"github.com/twmb/franz-go/pkg/kadm"
-	"github.com/twmb/franz-go/pkg/kerr"
 	"github.com/twmb/franz-go/pkg/kgo"
 )
 
@@ -38,19 +54,22 @@ import (
 type Config struct {
 	RedpandaBrokers []string
 	Port            int
-	Token           string
+	Token           string // legacy shared token for routers/hotplug
+	DBPath          string
 }
 
 var cfg = Config{
 	RedpandaBrokers: []string{"localhost:9092"},
 	Port:            3843,
 	Token:           "secret-token",
+	DBPath:          "data/client2server.db",
 }
 
 func loadConfigFromEnv() {
 	cfg.RedpandaBrokers = strings.Split(getEnvStr("REDPANDA_BROKERS", "localhost:9092"), ",")
 	cfg.Port = getEnvInt("PORT", cfg.Port)
 	cfg.Token = getEnvStr("TOKEN", cfg.Token)
+	cfg.DBPath = getEnvStr("DB_PATH", cfg.DBPath)
 }
 
 func getEnvStr(key, def string) string {
@@ -74,19 +93,17 @@ func getEnvInt(key string, def int) int {
 // Domain types
 // ----------------------------------------------------------------------------
 
-// RouterEvent - generic event sent from a router to the server.
 type RouterEvent struct {
-	ID          string                 `json:"id"`
-	RouterID    string                 `json:"router_id"`
-	Hostname    string                 `json:"hostname,omitempty"`
-	EventType   string                 `json:"event_type"`
-	Timestamp   time.Time              `json:"timestamp"`
-	Payload     map[string]interface{} `json:"payload"`
-	ReceivedAt  time.Time              `json:"received_at"`
-	Connection  string                 `json:"connection"` // "websocket" | "http"
+	ID         string                 `json:"id"`
+	RouterID   string                 `json:"router_id"`
+	Hostname   string                 `json:"hostname,omitempty"`
+	EventType  string                 `json:"event_type"`
+	Timestamp  time.Time              `json:"timestamp"`
+	Payload    map[string]interface{} `json:"payload"`
+	ReceivedAt time.Time              `json:"received_at"`
+	Connection string                 `json:"connection"`
 }
 
-// RouterCommand - command sent from server to a router.
 type RouterCommand struct {
 	ID       string            `json:"id"`
 	RouterID string            `json:"router_id"`
@@ -95,7 +112,6 @@ type RouterCommand struct {
 	SentAt   time.Time         `json:"sent_at"`
 }
 
-// CommandResult - reply from a router after running a command.
 type CommandResult struct {
 	Success bool   `json:"success"`
 	Output  string `json:"output"`
@@ -111,18 +127,18 @@ type Router struct {
 	LastSeen  time.Time
 	Conn      *websocket.Conn
 	Connected bool
-	writeMu   sync.Mutex // serialise writes to the WS connection
+	writeMu   sync.Mutex
 }
 
 var (
 	routersMu      sync.RWMutex
 	routers        = make(map[string]*Router)
 	routerQueuesMu sync.Mutex
-	routerQueues   = make(map[string][]RouterCommand) // queued while offline
+	routerQueues   = make(map[string][]RouterCommand)
 	pendingMu      sync.Mutex
-	pendingCmds    = make(map[string]chan CommandResult) // command_id -> result chan
+	pendingCmds    = make(map[string]chan CommandResult)
 	executedMu     sync.Mutex
-	executedCmds   = make(map[string]time.Time) // idempotency: cmd_id -> last run
+	executedCmds   = make(map[string]time.Time)
 )
 
 const (
@@ -134,7 +150,7 @@ const (
 )
 
 // ----------------------------------------------------------------------------
-// Redpanda (Kafka) client
+// Redpanda (Kafka) producer
 // ----------------------------------------------------------------------------
 
 var kcl *kgo.Client
@@ -150,21 +166,6 @@ func initRedpanda(ctx context.Context) error {
 		return fmt.Errorf("kafka client: %w", err)
 	}
 	kcl = cl
-
-	// Best-effort topic creation. Redpanda has auto-create enabled in dev, so
-	// this is just to make sure they exist with sane defaults.
-	adm := kadm.NewClient(cl)
-	topics := []string{"router-events", "router-commands"}
-	resp, err := adm.CreateTopics(ctx, 1, 1, nil, topics...)
-	if err != nil {
-		log.Printf("create topics admin call failed (non-fatal if auto-create is on): %v", err)
-		return nil
-	}
-	for _, ct := range resp {
-		if ct.Err != nil && !errors.Is(ct.Err, kerr.TopicAlreadyExists) {
-			log.Printf("topic %s: %v", ct.Topic, ct.Err)
-		}
-	}
 	return nil
 }
 
@@ -174,7 +175,6 @@ func publish(ctx context.Context, topic string, key string, value any) error {
 		return fmt.Errorf("marshal: %w", err)
 	}
 	rec := &kgo.Record{Topic: topic, Key: []byte(key), Value: data}
-	// Bound how long the HTTP handler can wait for the broker.
 	pctx, cancel := context.WithTimeout(ctx, publishTimeout)
 	defer cancel()
 	res := kcl.ProduceSync(pctx, rec)
@@ -185,11 +185,43 @@ func publish(ctx context.Context, topic string, key string, value any) error {
 }
 
 // ----------------------------------------------------------------------------
-// WebSocket handler
+// Event ingestion (called by both WS and HTTP)
+// ----------------------------------------------------------------------------
+
+func ingestEvent(ev RouterEvent) {
+	if ev.ID == "" {
+		ev.ID = uuid.New().String()
+	}
+	if ev.ReceivedAt.IsZero() {
+		ev.ReceivedAt = time.Now()
+	}
+	if ev.Timestamp.IsZero() {
+		ev.Timestamp = ev.ReceivedAt
+	}
+
+	// Metrics
+	globalMetrics.recordEvent(ev.EventType)
+
+	// Persist (best-effort)
+	if err := saveEvent(ev); err != nil {
+		log.Printf("save event: %v", err)
+	}
+
+	// Publish to Redpanda
+	if err := publish(context.Background(), "router-events", ev.RouterID, ev); err != nil {
+		log.Printf("publish event: %v", err)
+	}
+
+	// Broadcast to SSE clients
+	hub.broadcast("event", ev)
+}
+
+// ----------------------------------------------------------------------------
+// WebSocket handler (routers connect here)
 // ----------------------------------------------------------------------------
 
 func handleWebSocket(w http.ResponseWriter, r *http.Request) {
-	// Auth: token in query string (?token=...) or Authorization header
+	// Auth: legacy TOKEN only (routers use shared secret, not JWT)
 	token := r.URL.Query().Get("token")
 	if token == "" {
 		if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
@@ -202,7 +234,6 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 	}
 
 	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
-		// Disable permessage-deflate to keep things simple on minimal routers
 		CompressionMode: websocket.CompressionDisabled,
 	})
 	if err != nil {
@@ -214,14 +245,14 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 	ctx, cancel := context.WithCancel(r.Context())
 	defer cancel()
 
-	// First message must be a registration event
-	firstMsg, err := readRouterMessage(ctx, conn)
+	// First message is registration
+	raw, err := readRouterMessage(ctx, conn)
 	if err != nil {
 		log.Printf("ws read reg: %v", err)
 		return
 	}
 	var reg RouterEvent
-	if err := json.Unmarshal(firstMsg, &reg); err != nil {
+	if err := json.Unmarshal(raw, &reg); err != nil {
 		log.Printf("ws reg parse: %v", err)
 		return
 	}
@@ -240,10 +271,12 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 	}
 	routersMu.Unlock()
 
+	// Mark as back online (clears any offline alert)
+	acknowledgeRouterAlerts(routerID)
+
 	log.Printf("router connected: %s (from %s)", routerID, r.RemoteAddr)
 	flushQueuedCommands(ctx, routerID)
 
-	// Message loop
 	for {
 		raw, err := readRouterMessage(ctx, conn)
 		if err != nil {
@@ -256,13 +289,11 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 			continue
 		}
 
-		// Server-assigned fields
-		ev.ID = uuid.New().String()
-		ev.ReceivedAt = time.Now()
-		ev.Connection = "websocket"
-		if ev.Timestamp.IsZero() {
-			ev.Timestamp = ev.ReceivedAt
+		// Inherit router_id if missing
+		if ev.RouterID == "" {
+			ev.RouterID = routerID
 		}
+		ev.Connection = "websocket"
 
 		// Command result handling
 		if ev.EventType == "command_result" {
@@ -274,9 +305,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
-		if err := publish(ctx, "router-events", routerID, ev); err != nil {
-			log.Printf("publish event: %v", err)
-		}
+		ingestEvent(ev)
 		log.Printf("[%s] %s", routerID, ev.EventType)
 
 		routersMu.Lock()
@@ -295,24 +324,10 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
 }
 
 func readRouterMessage(ctx context.Context, conn *websocket.Conn) ([]byte, error) {
-	// coder/websocket: Read returns a Message
 	_, data, err := conn.Read(ctx)
 	return data, err
 }
 
-// writeJSON serialises writes to the connection.
-func (r *Router) writeJSON(ctx context.Context, v any) error {
-	data, err := json.Marshal(v)
-	if err != nil {
-		return err
-	}
-	wctx, cancel := context.WithTimeout(ctx, wsWriteTimeout)
-	defer cancel()
-	r.writeMu.Lock()
-	defer r.writeMu.Unlock()
-	return r.Conn.Write(wctx, websocket.MessageText, data)
-}
-
 func deliverCommandResult(cmdID string, payload map[string]interface{}) {
 	pendingMu.Lock()
 	ch, ok := pendingCmds[cmdID]
@@ -323,15 +338,17 @@ func deliverCommandResult(cmdID string, payload map[string]interface{}) {
 	if !ok {
 		return
 	}
-	res := CommandResult{
-		Success: payload["success"] == true,
-	}
+	res := CommandResult{Success: payload["success"] == true}
 	if s, ok := payload["output"].(string); ok {
 		res.Output = s
 	}
 	if s, ok := payload["error"].(string); ok {
 		res.Error = s
 	}
+	// Persist result to SQLite
+	_ = updateCommandResult(cmdID, "completed", &res)
+	globalMetrics.recordCommand(res.Success)
+
 	select {
 	case ch <- res:
 	default:
@@ -352,46 +369,42 @@ func flushQueuedCommands(ctx context.Context, routerID string) {
 		pendingMu.Lock()
 		pendingCmds[cmd.ID] = make(chan CommandResult, 1)
 		pendingMu.Unlock()
+		_ = updateCommandResult(cmd.ID, "delivered", nil)
 		if err := publish(ctx, "router-commands", routerID, cmd); err != nil {
 			log.Printf("queue flush publish: %v", err)
 		}
 	}
 }
 
+func acknowledgeRouterAlerts(routerID string) {
+	// Best-effort: mark all unacked offline alerts for this router as resolved
+	if db == nil {
+		return
+	}
+	_, _ = db.Exec("UPDATE alerts SET acknowledged_at = CURRENT_TIMESTAMP WHERE router_id = ? AND kind = 'router_offline' AND acknowledged_at IS NULL", routerID)
+}
+
 // ----------------------------------------------------------------------------
-// HTTP handlers
+// HTTP API
 // ----------------------------------------------------------------------------
 
-func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
-	if r.Method != http.MethodPost {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
-	if !authorised(r) {
-		http.Error(w, "unauthorized", http.StatusUnauthorized)
-		return
-	}
-	var ev RouterEvent
-	if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
-		http.Error(w, "invalid json", http.StatusBadRequest)
-		return
-	}
-	ev.ID = uuid.New().String()
-	ev.ReceivedAt = time.Now()
-	ev.Connection = "http"
-	if ev.Timestamp.IsZero() {
-		ev.Timestamp = ev.ReceivedAt
-	}
-	if err := publish(r.Context(), "router-events", ev.RouterID, ev); err != nil {
-		log.Printf("publish event: %v", err)
-		http.Error(w, "publish failed", http.StatusBadGateway)
-		return
+func handleHealth(w http.ResponseWriter, r *http.Request) {
+	routersMu.RLock()
+	onlineCount := 0
+	total := len(routers)
+	for _, rt := range routers {
+		if time.Since(rt.LastSeen) < offlineThreshold {
+			onlineCount++
+		}
 	}
+	routersMu.RUnlock()
 	w.Header().Set("Content-Type", "application/json")
-	_ = json.NewEncoder(w).Encode(map[string]string{
-		"event_id":  ev.ID,
-		"router_id": ev.RouterID,
-		"status":    "accepted",
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{
+		"status":         "ok",
+		"routers":        total,
+		"routers_online": onlineCount,
+		"redpanda":       cfg.RedpandaBrokers,
+		"version":        "2.1.0",
 	})
 }
 
@@ -417,12 +430,55 @@ func handleRouters(w http.ResponseWriter, r *http.Request) {
 	_ = json.NewEncoder(w).Encode(map[string]interface{}{"routers": list})
 }
 
+func handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	// Accept either JWT or legacy shared TOKEN
+	if !authorisedRouter(r) {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	var ev RouterEvent
+	if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
+		http.Error(w, "invalid json", http.StatusBadRequest)
+		return
+	}
+	ev.Connection = "http"
+	ingestEvent(ev)
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]string{
+		"event_id":  ev.ID,
+		"router_id": ev.RouterID,
+		"status":    "accepted",
+	})
+	_ = ev
+}
+
+func handleListEvents(w http.ResponseWriter, r *http.Request) {
+	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+	if limit <= 0 || limit > 1000 {
+		limit = 100
+	}
+	routerID := r.URL.Query().Get("router_id")
+	eventType := r.URL.Query().Get("event_type")
+	events, err := listEvents(limit, routerID, eventType)
+	if err != nil {
+		http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{"events": events})
+}
+
 func handleCommand(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		return
 	}
-	if !authorised(r) {
+	// JWT or legacy TOKEN
+	if !authorisedAny(r) {
 		http.Error(w, "unauthorized", http.StatusUnauthorized)
 		return
 	}
@@ -463,6 +519,15 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 	cmd.RouterID = routerID
 	cmd.SentAt = time.Now()
 
+	// Identify the issuer (JWT username) if present
+	issuedBy := "token"
+	if claims := claimsFromHeader(r); claims != nil {
+		issuedBy = claims.Username
+	}
+
+	// Persist
+	_ = saveCommand(cmd, issuedBy, "pending")
+
 	routersMu.RLock()
 	router, online := routers[routerID]
 	routersMu.RUnlock()
@@ -473,6 +538,7 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 		depth := len(routerQueues[routerID])
 		routerQueuesMu.Unlock()
 		log.Printf("queued cmd %s for %s (depth=%d)", cmd.Command, routerID, depth)
+		_ = updateCommandResult(cmd.ID, "queued", nil)
 		w.Header().Set("Content-Type", "application/json")
 		_ = json.NewEncoder(w).Encode(map[string]interface{}{
 			"command_id":  cmd.ID,
@@ -483,7 +549,6 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Online: register waiter, publish, wait for result
 	resultCh := make(chan CommandResult, 1)
 	pendingMu.Lock()
 	pendingCmds[cmd.ID] = resultCh
@@ -493,10 +558,11 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 		pendingMu.Lock()
 		delete(pendingCmds, cmd.ID)
 		pendingMu.Unlock()
-		log.Printf("publish command: %v", err)
-		http.Error(w, "publish failed", http.StatusBadGateway)
+		_ = updateCommandResult(cmd.ID, "publish_failed", nil)
+		http.Error(w, "publish failed: "+err.Error(), http.StatusBadGateway)
 		return
 	}
+	_ = updateCommandResult(cmd.ID, "delivered", nil)
 
 	log.Printf("cmd %s -> %s (waiting)", cmd.Command, routerID)
 	w.Header().Set("Content-Type", "application/json")
@@ -514,6 +580,7 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 		pendingMu.Lock()
 		delete(pendingCmds, cmd.ID)
 		pendingMu.Unlock()
+		_ = updateCommandResult(cmd.ID, "timeout", nil)
 		_ = json.NewEncoder(w).Encode(map[string]string{
 			"command_id": cmd.ID,
 			"status":     "timeout",
@@ -522,38 +589,144 @@ func handleCommand(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func handleHealth(w http.ResponseWriter, r *http.Request) {
-	routersMu.RLock()
-	onlineCount := 0
-	for _, rt := range routers {
-		if time.Since(rt.LastSeen) < offlineThreshold {
-			onlineCount++
+func handleListCommands(w http.ResponseWriter, r *http.Request) {
+	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+	if limit <= 0 || limit > 500 {
+		limit = 50
+	}
+	routerID := r.URL.Query().Get("router_id")
+	cmds, err := listCommands(limit, routerID)
+	if err != nil {
+		http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{"commands": cmds})
+}
+
+func handleMetrics(w http.ResponseWriter, r *http.Request) {
+	sinceStr := r.URL.Query().Get("since")
+	since := 1 * time.Hour
+	if sinceStr != "" {
+		if d, err := time.ParseDuration(sinceStr); err == nil {
+			since = d
 		}
 	}
-	total := len(routers)
-	routersMu.RUnlock()
 	w.Header().Set("Content-Type", "application/json")
 	_ = json.NewEncoder(w).Encode(map[string]interface{}{
-		"status":         "ok",
-		"routers":        total,
-		"routers_online": onlineCount,
-		"redpanda":       cfg.RedpandaBrokers,
+		"summary":  globalMetrics.summary(),
+		"buckets":  globalMetrics.snapshot(since),
+		"since":    since.String(),
+	})
+}
+
+func handleAlerts(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodPost {
+		// Acknowledge: POST /api/alerts/{id}/ack
+		// Path is set up by the mux (see main)
+		http.Error(w, "use POST /api/alerts/{id}/ack", http.StatusMethodNotAllowed)
+		return
+	}
+	unack := r.URL.Query().Get("unack") == "1"
+	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+	if limit <= 0 || limit > 500 {
+		limit = 100
+	}
+	alerts, err := listAlerts(unack, limit)
+	if err != nil {
+		http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{"alerts": alerts})
+}
+
+func handleAckAlert(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	// Extract id from /api/alerts/{id}/ack
+	parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+	if len(parts) < 3 {
+		http.Error(w, "bad path", http.StatusBadRequest)
+		return
+	}
+	id, err := strconv.ParseInt(parts[2], 10, 64)
+	if err != nil {
+		http.Error(w, "bad id", http.StatusBadRequest)
+		return
+	}
+	if err := acknowledgeAlert(id); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]string{"status": "acknowledged"})
+}
+
+func handleMe(w http.ResponseWriter, r *http.Request) {
+	claims := claimsFromHeader(r)
+	if claims == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(map[string]interface{}{
+		"user_id":  claims.UserID,
+		"username": claims.Username,
+		"role":     claims.Role,
+		"expires":  claims.ExpiresAt,
 	})
 }
 
-func authorised(r *http.Request) bool {
-	h := r.Header.Get("Authorization")
-	if h == cfg.Token { // legacy: raw token
+// ----------------------------------------------------------------------------
+// Authorisation helpers
+// ----------------------------------------------------------------------------
+
+func claimsFromHeader(r *http.Request) *Claims {
+	auth := r.Header.Get("Authorization")
+	token := trimBearer(auth)
+	if token == "" || token == cfg.Token {
+		return nil
+	}
+	claims, err := ParseJWT(token)
+	if err != nil {
+		return nil
+	}
+	return claims
+}
+
+func authorisedAny(r *http.Request) bool {
+	auth := r.Header.Get("Authorization")
+	token := trimBearer(auth)
+	if token == "" {
+		return false
+	}
+	if token == cfg.Token {
 		return true
 	}
-	if strings.HasPrefix(h, "Bearer ") && strings.TrimPrefix(h, "Bearer ") == cfg.Token {
+	_, err := ParseJWT(token)
+	return err == nil
+}
+
+func authorisedRouter(r *http.Request) bool {
+	auth := r.Header.Get("Authorization")
+	token := trimBearer(auth)
+	if token == "" {
+		return false
+	}
+	// Routers use the legacy shared TOKEN. JWT users are valid too (in case
+	// someone scripts an event submission from the dashboard).
+	if token == cfg.Token {
 		return true
 	}
-	return false
+	_, err := ParseJWT(token)
+	return err == nil
 }
 
 // ----------------------------------------------------------------------------
-// Background cleanup
+// Background jobs
 // ----------------------------------------------------------------------------
 
 func startJanitor(ctx context.Context) {
@@ -578,6 +751,32 @@ func startJanitor(ctx context.Context) {
 	}()
 }
 
+func startOfflineWatcher(ctx context.Context) {
+	go func() {
+		t := time.NewTicker(30 * time.Second)
+		defer t.Stop()
+		known := map[string]bool{}
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-t.C:
+				routersMu.RLock()
+				current := map[string]bool{}
+				for id, rt := range routers {
+					online := time.Since(rt.LastSeen) < offlineThreshold
+					current[id] = online
+					if !online && !known[id] {
+						createAlert(id, "router_offline", fmt.Sprintf("Router %s has been offline for %s", id, time.Since(rt.LastSeen).Round(time.Second)))
+					}
+				}
+				routersMu.RUnlock()
+				known = current
+			}
+		}
+	}()
+}
+
 // ----------------------------------------------------------------------------
 // main
 // ----------------------------------------------------------------------------
@@ -585,8 +784,16 @@ func startJanitor(ctx context.Context) {
 func main() {
 	loadConfigFromEnv()
 	log.SetFlags(log.LstdFlags | log.Lshortfile)
-	log.Printf("=== client2server Go Server ===")
-	log.Printf("port=%d brokers=%v", cfg.Port, cfg.RedpandaBrokers)
+	log.Printf("=== client2server v2.1 ===")
+	log.Printf("port=%d brokers=%v db=%s", cfg.Port, cfg.RedpandaBrokers, cfg.DBPath)
+
+	// Ensure DB directory exists
+	if err := os.MkdirAll(strings.TrimSuffix(cfg.DBPath, "/"+pathBase(cfg.DBPath)), 0755); err != nil {
+		log.Printf("mkdir db: %v", err)
+	}
+	if err := initStore(cfg.DBPath); err != nil {
+		log.Fatalf("init store: %v", err)
+	}
 
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
@@ -595,25 +802,49 @@ func main() {
 		log.Printf("redpanda init failed (continuing): %v", err)
 	} else {
 		defer kcl.Close()
+		// Try to ensure topics exist (best effort)
+		if err := ensureTopics(ctx); err != nil {
+			log.Printf("ensure topics: %v", err)
+		}
+		// Start the consumer that persists to SQLite
+		go startConsumer(ctx)
 	}
+
 	startJanitor(ctx)
+	startOfflineWatcher(ctx)
 
 	mux := http.NewServeMux()
+
+	// Public
 	mux.HandleFunc("/health", handleHealth)
+	mux.HandleFunc("/", handleHealth)
+
+	// Auth
+	mux.HandleFunc("/api/auth/login", handleLogin)
+
+	// Router-facing (legacy token OR JWT)
 	mux.HandleFunc("/api/events", handleHTTPEvent)
-	mux.HandleFunc("/api/routers", handleRouters)
-	mux.HandleFunc("/api/command", handleCommand)
+	mux.HandleFunc("/api/events/", handleHTTPEvent)
 	mux.HandleFunc("/ws", handleWebSocket)
-	mux.HandleFunc("/", handleHealth)
+
+	// Dashboard
+	mux.HandleFunc("/api/routers", handleRouters)
+	mux.HandleFunc("/api/events/list", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListEvents))
+	mux.HandleFunc("/api/command", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleCommand))
+	mux.HandleFunc("/api/commands", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleListCommands))
+	mux.HandleFunc("/api/metrics", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMetrics))
+	mux.HandleFunc("/api/alerts", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAlerts))
+	mux.HandleFunc("/api/alerts/", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleAckAlert))
+	mux.HandleFunc("/api/auth/me", requireRole(RoleUser, RoleProjectAdmin, RoleSystemAdmin)(handleMe))
+	mux.HandleFunc("/api/events/stream", handleSSEStream)
 
 	srv := &http.Server{
 		Addr:         fmt.Sprintf(":%d", cfg.Port),
 		Handler:      mux,
-		ReadTimeout:  0, // WS connections are long-lived
+		ReadTimeout:  0,
 		WriteTimeout: 0,
 	}
 
-	// Graceful shutdown
 	go func() {
 		sigCh := make(chan os.Signal, 1)
 		signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -637,3 +868,20 @@ func main() {
 		log.Fatal(err)
 	}
 }
+
+func pathBase(p string) string {
+	i := strings.LastIndex(p, "/")
+	if i < 0 {
+		return p
+	}
+	return p[i+1:]
+}
+
+func ensureTopics(ctx context.Context) error {
+	if kcl == nil {
+		return nil
+	}
+	// Best-effort, log only
+	log.Println("redpanda: topics will be auto-created on first publish")
+	return nil
+}

+ 117 - 0
server/metrics.go

@@ -0,0 +1,117 @@
+// client2server - Metrics aggregation
+//
+// In-memory time-bucketed counters for events and commands. Buckets are
+// 1-minute windows covering the last 24 hours (1440 buckets).
+
+package main
+
+import (
+	"sync"
+	"time"
+)
+
+const (
+	bucketMinutes    = 1
+	retentionBuckets = 24 * 60 // 24h
+)
+
+type metricsBucket struct {
+	start         time.Time
+	eventsTotal   int
+	byType        map[string]int
+	commandsTotal int
+	commandsOK    int
+	commandsFail  int
+}
+
+type Metrics struct {
+	mu      sync.RWMutex
+	buckets [retentionBuckets]*metricsBucket
+}
+
+var globalMetrics = &Metrics{}
+
+func (m *Metrics) nowBucket() *metricsBucket {
+	startOfMinute := time.Now().Truncate(bucketMinutes * time.Minute)
+	idx := int(startOfMinute.Unix()/60) % retentionBuckets
+	b := m.buckets[idx]
+	if b == nil || !b.start.Equal(startOfMinute) {
+		b = &metricsBucket{
+			start:  startOfMinute,
+			byType: make(map[string]int),
+		}
+		m.buckets[idx] = b
+	}
+	return b
+}
+
+func (m *Metrics) recordEvent(eventType string) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	b := m.nowBucket()
+	b.eventsTotal++
+	b.byType[eventType]++
+}
+
+func (m *Metrics) recordCommand(success bool) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	b := m.nowBucket()
+	b.commandsTotal++
+	if success {
+		b.commandsOK++
+	} else {
+		b.commandsFail++
+	}
+}
+
+// snapshot returns a list of buckets covering the last `since` minutes.
+func (m *Metrics) snapshot(since time.Duration) []map[string]interface{} {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	cutoff := time.Now().Add(-since).Truncate(bucketMinutes * time.Minute)
+	out := []map[string]interface{}{}
+
+	// Walk buckets; for each one, check if its start >= cutoff
+	for _, b := range m.buckets {
+		if b == nil {
+			continue
+		}
+		if b.start.Before(cutoff) {
+			continue
+		}
+		out = append(out, map[string]interface{}{
+			"ts":            b.start.Format(time.RFC3339),
+			"events_total":  b.eventsTotal,
+			"commands_total": b.commandsTotal,
+			"commands_ok":   b.commandsOK,
+			"commands_fail": b.commandsFail,
+			"events_by_type": b.byType,
+		})
+	}
+	return out
+}
+
+func (m *Metrics) summary() map[string]interface{} {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	b := m.nowBucket()
+	// Aggregate events from this minute and previous 5 minutes for a more
+	// meaningful "recent" number.
+	total := b.eventsTotal
+	for i := 1; i < 6; i++ {
+		idx := (int(time.Now().Unix()/60) - i) % retentionBuckets
+		if idx < 0 {
+			idx += retentionBuckets
+		}
+		if prev := m.buckets[idx]; prev != nil {
+			total += prev.eventsTotal
+		}
+	}
+	return map[string]interface{}{
+		"events_last_5min":  total,
+		"events_this_min":   b.eventsTotal,
+		"commands_this_min": b.commandsTotal,
+	}
+}

+ 147 - 0
server/sse.go

@@ -0,0 +1,147 @@
+// client2server - Server-Sent Events broadcaster
+//
+// Used by the dashboard for real-time event feed. Clients connect to
+// /api/events/stream and receive a newline-delimited stream of JSON
+// envelopes.
+
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"sync"
+	"time"
+)
+
+type sseClient struct {
+	channel  chan []byte
+	filter   sseFilter
+	lastPing time.Time
+}
+
+type sseFilter struct {
+	RouterID  string
+	EventType string
+}
+
+type sseHub struct {
+	mu      sync.RWMutex
+	clients map[*sseClient]struct{}
+}
+
+var hub = &sseHub{
+	clients: make(map[*sseClient]struct{}),
+}
+
+func (h *sseHub) add(c *sseClient) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	h.clients[c] = struct{}{}
+}
+
+func (h *sseHub) remove(c *sseClient) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	delete(h.clients, c)
+	close(c.channel)
+}
+
+func (h *sseHub) broadcast(eventType string, payload any) {
+	env := map[string]any{
+		"type":      eventType,
+		"payload":   payload,
+		"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
+	}
+	data, err := json.Marshal(env)
+	if err != nil {
+		return
+	}
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+	for c := range h.clients {
+		if c.filter.RouterID != "" {
+			if rid, ok := payload.(map[string]any)["router_id"].(string); ok && rid != c.filter.RouterID {
+				continue
+			}
+		}
+		if c.filter.EventType != "" && c.filter.EventType != eventType {
+			continue
+		}
+		select {
+		case c.channel <- data:
+		default:
+			// drop if client too slow
+		}
+	}
+}
+
+func handleSSEStream(w http.ResponseWriter, r *http.Request) {
+	// Auth via Bearer JWT or legacy token
+	token := r.Header.Get("Authorization")
+	if t := r.URL.Query().Get("token"); t != "" {
+		token = "Bearer " + t
+	}
+	token = trimBearer(token)
+	if token == "" {
+		http.Error(w, "missing token", http.StatusUnauthorized)
+		return
+	}
+	// Accept either a JWT or the legacy shared TOKEN
+	if _, err := ParseJWT(token); err != nil && token != cfg.Token {
+		http.Error(w, "invalid token", http.StatusUnauthorized)
+		return
+	}
+
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "streaming not supported", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("X-Accel-Buffering", "no")
+	w.WriteHeader(http.StatusOK)
+	flusher.Flush()
+
+	client := &sseClient{
+		channel:  make(chan []byte, 64),
+		filter:   sseFilter{RouterID: r.URL.Query().Get("router_id"), EventType: r.URL.Query().Get("event_type")},
+		lastPing: time.Now(),
+	}
+	hub.add(client)
+	defer hub.remove(client)
+
+	// Initial comment so curl shows connection is live
+	_, _ = fmt.Fprintf(w, ": connected\n\n")
+	flusher.Flush()
+
+	pingTicker := time.NewTicker(15 * time.Second)
+	defer pingTicker.Stop()
+
+	for {
+		select {
+		case <-r.Context().Done():
+			return
+		case data := <-client.channel:
+			if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
+				return
+			}
+			flusher.Flush()
+		case <-pingTicker.C:
+			if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
+				return
+			}
+			flusher.Flush()
+		}
+	}
+}
+
+func trimBearer(s string) string {
+	if len(s) > 7 && s[:7] == "Bearer " {
+		return s[7:]
+	}
+	return s
+}

+ 281 - 0
server/store.go

@@ -0,0 +1,281 @@
+// client2server - Persistent storage (SQLite)
+//
+// Tables:
+//   users(id, username, password_hash, role, created_at)
+//   events(id, router_id, event_type, hostname, payload_json, ts, received_at)
+//   commands(id, router_id, command, args_json, issued_by, status, result, ts)
+//   alerts(id, router_id, kind, message, created_at, acknowledged_at)
+
+package main
+
+import (
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"log"
+	"sync"
+	"time"
+
+	_ "modernc.org/sqlite"
+)
+
+var (
+	db   *sql.DB
+	dbMu sync.Mutex
+)
+
+func initStore(path string) error {
+	d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
+	if err != nil {
+		return fmt.Errorf("open sqlite: %w", err)
+	}
+	d.SetMaxOpenConns(1) // SQLite single-writer; many readers OK
+	if err := d.Ping(); err != nil {
+		return fmt.Errorf("ping sqlite: %w", err)
+	}
+	db = d
+
+	schema := `
+CREATE TABLE IF NOT EXISTS users (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    username TEXT UNIQUE NOT NULL,
+    password_hash TEXT NOT NULL,
+    role TEXT NOT NULL DEFAULT 'user',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS events (
+    id TEXT PRIMARY KEY,
+    router_id TEXT NOT NULL,
+    event_type TEXT NOT NULL,
+    hostname TEXT,
+    payload_json TEXT,
+    ts DATETIME,
+    received_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_events_router ON events(router_id);
+CREATE INDEX IF NOT EXISTS idx_events_ts ON events(received_at);
+CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
+
+CREATE TABLE IF NOT EXISTS commands (
+    id TEXT PRIMARY KEY,
+    router_id TEXT NOT NULL,
+    command TEXT NOT NULL,
+    args_json TEXT,
+    issued_by TEXT,
+    status TEXT NOT NULL,
+    result_json TEXT,
+    ts DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_commands_router ON commands(router_id);
+CREATE INDEX IF NOT EXISTS idx_commands_ts ON commands(ts);
+
+CREATE TABLE IF NOT EXISTS alerts (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    router_id TEXT NOT NULL,
+    kind TEXT NOT NULL,
+    message TEXT NOT NULL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    acknowledged_at DATETIME
+);
+CREATE INDEX IF NOT EXISTS idx_alerts_unack ON alerts(acknowledged_at);
+`
+	if _, err := db.Exec(schema); err != nil {
+		return fmt.Errorf("schema: %w", err)
+	}
+
+	// Default admin user (username: admin, password: admin)
+	// Only if no users exist
+	var count int
+	if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
+		return err
+	}
+	if count == 0 {
+		hash, err := HashPassword("admin")
+		if err != nil {
+			return err
+		}
+		_, err = db.Exec(
+			"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
+			"admin", hash, "system_admin",
+		)
+		if err != nil {
+			return err
+		}
+		log.Println("created default admin user (username=admin password=admin) — CHANGE IT")
+	}
+
+	return nil
+}
+
+func saveEvent(ev RouterEvent) error {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	payload, _ := json.Marshal(ev.Payload)
+	var hostname *string
+	if ev.Hostname != "" {
+		hostname = &ev.Hostname
+	}
+	// Use ReceivedAt as fallback so listEvents never sees a zero time.
+	ts := ev.ReceivedAt
+	if !ev.Timestamp.IsZero() {
+		ts = ev.Timestamp
+	}
+	_, err := db.Exec(
+		`INSERT OR REPLACE INTO events (id, router_id, event_type, hostname, payload_json, ts, received_at)
+		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
+		ev.ID, ev.RouterID, ev.EventType, hostname, string(payload), ts, ev.ReceivedAt,
+	)
+	return err
+}
+
+func listEvents(limit int, routerID, eventType string) ([]RouterEvent, error) {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	q := "SELECT id, router_id, event_type, COALESCE(hostname, ''), COALESCE(payload_json, '{}'), ts, received_at FROM events WHERE 1=1"
+	args := []interface{}{}
+	if routerID != "" {
+		q += " AND router_id = ?"
+		args = append(args, routerID)
+	}
+	if eventType != "" {
+		q += " AND event_type = ?"
+		args = append(args, eventType)
+	}
+	q += " ORDER BY received_at DESC LIMIT ?"
+	args = append(args, limit)
+
+	rows, err := db.Query(q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	out := make([]RouterEvent, 0, limit)
+	for rows.Next() {
+		var ev RouterEvent
+		var payloadJSON string
+		var ts time.Time
+		if err := rows.Scan(&ev.ID, &ev.RouterID, &ev.EventType, &ev.Hostname, &payloadJSON, &ts, &ev.ReceivedAt); err != nil {
+			return nil, err
+		}
+		_ = json.Unmarshal([]byte(payloadJSON), &ev.Payload)
+		ev.Timestamp = ts
+		out = append(out, ev)
+	}
+	return out, nil
+}
+
+func saveCommand(cmd RouterCommand, issuedBy, status string) error {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	argsJSON, _ := json.Marshal(cmd.Args)
+	_, err := db.Exec(
+		`INSERT OR REPLACE INTO commands (id, router_id, command, args_json, issued_by, status) VALUES (?, ?, ?, ?, ?, ?)`,
+		cmd.ID, cmd.RouterID, cmd.Command, string(argsJSON), issuedBy, status,
+	)
+	return err
+}
+
+func updateCommandResult(cmdID, status string, result *CommandResult) error {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	var resultJSON []byte
+	if result != nil {
+		resultJSON, _ = json.Marshal(result)
+	}
+	_, err := db.Exec(
+		"UPDATE commands SET status = ?, result_json = ? WHERE id = ?",
+		status, string(resultJSON), cmdID,
+	)
+	return err
+}
+
+func listCommands(limit int, routerID string) ([]map[string]interface{}, error) {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	q := `SELECT id, router_id, command, COALESCE(args_json, '{}'), COALESCE(issued_by, ''), status, COALESCE(result_json, ''), ts
+	      FROM commands WHERE 1=1`
+	args := []interface{}{}
+	if routerID != "" {
+		q += " AND router_id = ?"
+		args = append(args, routerID)
+	}
+	q += " ORDER BY ts DESC LIMIT ?"
+	args = append(args, limit)
+
+	rows, err := db.Query(q, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	out := []map[string]interface{}{}
+	for rows.Next() {
+		var id, routerID, command, argsJSON, issuedBy, status, resultJSON, ts string
+		if err := rows.Scan(&id, &routerID, &command, &argsJSON, &issuedBy, &status, &resultJSON, &ts); err != nil {
+			return nil, err
+		}
+		entry := map[string]interface{}{
+			"id": id, "router_id": routerID, "command": command,
+			"issued_by": issuedBy, "status": status, "ts": ts,
+		}
+		var args map[string]string
+		_ = json.Unmarshal([]byte(argsJSON), &args)
+		entry["args"] = args
+		if resultJSON != "" {
+			var res map[string]interface{}
+			_ = json.Unmarshal([]byte(resultJSON), &res)
+			entry["result"] = res
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}
+
+func createAlert(routerID, kind, message string) {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	_, _ = db.Exec(
+		`INSERT INTO alerts (router_id, kind, message) VALUES (?, ?, ?)`,
+		routerID, kind, message,
+	)
+}
+
+func listAlerts(unackOnly bool, limit int) ([]map[string]interface{}, error) {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	q := `SELECT id, router_id, kind, message, created_at, COALESCE(acknowledged_at, '') FROM alerts`
+	if unackOnly {
+		q += ` WHERE acknowledged_at IS NULL`
+	}
+	q += ` ORDER BY created_at DESC LIMIT ?`
+	rows, err := db.Query(q, limit)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	out := []map[string]interface{}{}
+	for rows.Next() {
+		var id int64
+		var routerID, kind, message, createdAt, ackedAt string
+		if err := rows.Scan(&id, &routerID, &kind, &message, &createdAt, &ackedAt); err != nil {
+			return nil, err
+		}
+		entry := map[string]interface{}{
+			"id": id, "router_id": routerID, "kind": kind,
+			"message": message, "created_at": createdAt,
+		}
+		if ackedAt != "" {
+			entry["acknowledged_at"] = ackedAt
+		}
+		out = append(out, entry)
+	}
+	return out, nil
+}
+
+func acknowledgeAlert(id int64) error {
+	dbMu.Lock()
+	defer dbMu.Unlock()
+	_, err := db.Exec("UPDATE alerts SET acknowledged_at = CURRENT_TIMESTAMP WHERE id = ?", id)
+	return err
+}