Feat v0.6.4 admin health/metrics tab + cluster merge
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m40s
CI/CD / test-sqlite (pull_request) Successful in 2m46s
CI/CD / build-and-deploy (pull_request) Successful in 1m1s

Admin Health tab under Monitoring with auto-refreshing runtime, DB,
cluster, and extension metrics panels. New GET /api/v1/admin/metrics
endpoint. Fattened heartbeat JSONB with stack, GC CPU%, extension
count, sandbox stats, trigger fires. Sandbox runner and trigger engine
now track cumulative execution counters. Event bus tracks publish/deliver
counts. Health endpoints consolidated via shared builder. Block renderers
(mermaid, katex, csv-table, diff-viewer) no longer require chat.
cluster-dashboard package retired — merged into admin Health tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 13:06:09 +00:00
parent 3d4228f868
commit 4bc11c2f4e
22 changed files with 917 additions and 281 deletions

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/metrics"
"switchboard-core/store"
)
// mockHub implements metrics.ConnCounter for tests.
type mockHub struct{ count int }
func (m *mockHub) ConnCount() int { return m.count }
// mockBus implements metrics.BusCounter for tests.
type mockBus struct{ pub, del int64 }
func (m *mockBus) PublishCount() int64 { return m.pub }
func (m *mockBus) DeliverCount() int64 { return m.del }
func TestMetrics_SQLiteShape(t *testing.T) {
gin.SetMode(gin.TestMode)
collector := metrics.NewCollector(
nil, // no DB
&mockHub{count: 5},
&mockBus{pub: 10, del: 20},
store.Stores{}, // no cluster store
func() (uint64, uint64, float64) { return 42, 3, 12.5 },
func() int64 { return 7 },
time.Now().Add(-10*time.Minute),
)
h := NewMetricsHandler(collector)
r := gin.New()
r.GET("/api/v1/admin/metrics", h.GetMetrics)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
var snap metrics.Snapshot
if err := json.Unmarshal(w.Body.Bytes(), &snap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Runtime
if snap.Runtime.WSClients != 5 {
t.Errorf("ws_clients = %d, want 5", snap.Runtime.WSClients)
}
if snap.Runtime.UptimeSec < 600 {
t.Errorf("uptime_sec = %f, want >= 600", snap.Runtime.UptimeSec)
}
// Cluster should be nil (SQLite)
if snap.Cluster != nil {
t.Errorf("cluster should be nil for SQLite, got %+v", snap.Cluster)
}
// Extensions
if snap.Extensions.StarlarkExecTotal != 42 {
t.Errorf("starlark_exec_total = %d, want 42", snap.Extensions.StarlarkExecTotal)
}
if snap.Extensions.StarlarkErrorsTotal != 3 {
t.Errorf("starlark_errors_total = %d, want 3", snap.Extensions.StarlarkErrorsTotal)
}
if snap.Extensions.StarlarkAvgDuration != 12.5 {
t.Errorf("starlark_avg_duration_ms = %f, want 12.5", snap.Extensions.StarlarkAvgDuration)
}
if snap.Extensions.TriggerFiresTotal != 7 {
t.Errorf("trigger_fires_total = %d, want 7", snap.Extensions.TriggerFiresTotal)
}
if snap.Extensions.EventBusPublished != 10 {
t.Errorf("event_bus_published = %d, want 10", snap.Extensions.EventBusPublished)
}
if snap.Extensions.EventBusDelivered != 20 {
t.Errorf("event_bus_delivered = %d, want 20", snap.Extensions.EventBusDelivered)
}
}
func TestMetrics_WithCluster(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := &mockClusterStore{
nodes: []store.ClusterNode{
{
NodeID: "node-1",
Endpoint: "http://node-1:8080",
Heartbeat: time.Now(),
Stats: json.RawMessage(`{"uptime_sec":120,"ws_clients":3}`),
},
},
}
collector := metrics.NewCollector(
nil,
&mockHub{count: 3},
&mockBus{},
store.Stores{Cluster: mock},
func() (uint64, uint64, float64) { return 0, 0, 0 },
func() int64 { return 0 },
time.Now(),
)
h := NewMetricsHandler(collector)
r := gin.New()
r.GET("/api/v1/admin/metrics", h.GetMetrics)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d, want 200", w.Code)
}
var snap metrics.Snapshot
if err := json.Unmarshal(w.Body.Bytes(), &snap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if snap.Cluster == nil {
t.Fatal("cluster should not be nil with cluster store")
}
if snap.Cluster.Size != 1 {
t.Errorf("cluster.size = %d, want 1", snap.Cluster.Size)
}
if snap.Cluster.Nodes[0].NodeID != "node-1" {
t.Errorf("node_id = %q, want %q", snap.Cluster.Nodes[0].NodeID, "node-1")
}
if snap.Cluster.Nodes[0].UptimeSec != 120 {
t.Errorf("uptime_sec = %f, want 120", snap.Cluster.Nodes[0].UptimeSec)
}
}
func TestMetrics_ExtensionCounters(t *testing.T) {
gin.SetMode(gin.TestMode)
collector := metrics.NewCollector(
nil,
&mockHub{},
&mockBus{pub: 100, del: 500},
store.Stores{},
func() (uint64, uint64, float64) { return 1000, 50, 8.3 },
func() int64 { return 25 },
time.Now(),
)
h := NewMetricsHandler(collector)
r := gin.New()
r.GET("/api/v1/admin/metrics", h.GetMetrics)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/metrics", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var snap metrics.Snapshot
_ = json.Unmarshal(w.Body.Bytes(), &snap)
if snap.Extensions.StarlarkExecTotal != 1000 {
t.Errorf("exec total = %d, want 1000", snap.Extensions.StarlarkExecTotal)
}
if snap.Extensions.EventBusPublished != 100 {
t.Errorf("bus published = %d, want 100", snap.Extensions.EventBusPublished)
}
if snap.Extensions.EventBusDelivered != 500 {
t.Errorf("bus delivered = %d, want 500", snap.Extensions.EventBusDelivered)
}
}