From 349ff5c80a7dd4b83122f0ca573e48c9de73862f Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Tue, 31 Mar 2026 13:40:18 +0000 Subject: [PATCH] Fix health tab: node identity indicator + cluster card overflow Add node_id to metrics snapshot so the health tab shows which instance the user is viewing from. Hoist nodeID computation so it works on both SQLite and Postgres deployments. Fix cluster node card overflow with text-overflow ellipsis and proper flex constraints. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/handlers/admin_metrics_test.go | 8 ++++++++ server/main.go | 15 +++++++++------ server/metrics/collector.go | 7 +++++-- src/js/sw/surfaces/admin/health.js | 17 +++++++++-------- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/server/handlers/admin_metrics_test.go b/server/handlers/admin_metrics_test.go index 268efa4..0285dbe 100644 --- a/server/handlers/admin_metrics_test.go +++ b/server/handlers/admin_metrics_test.go @@ -28,6 +28,7 @@ func TestMetrics_SQLiteShape(t *testing.T) { gin.SetMode(gin.TestMode) collector := metrics.NewCollector( + "test-node", nil, // no DB &mockHub{count: 5}, &mockBus{pub: 10, del: 20}, @@ -54,6 +55,11 @@ func TestMetrics_SQLiteShape(t *testing.T) { t.Fatalf("unmarshal: %v", err) } + // Node ID + if snap.NodeID != "test-node" { + t.Errorf("node_id = %q, want %q", snap.NodeID, "test-node") + } + // Runtime if snap.Runtime.WSClients != 5 { t.Errorf("ws_clients = %d, want 5", snap.Runtime.WSClients) @@ -103,6 +109,7 @@ func TestMetrics_WithCluster(t *testing.T) { } collector := metrics.NewCollector( + "test-node", nil, &mockHub{count: 3}, &mockBus{}, @@ -147,6 +154,7 @@ func TestMetrics_ExtensionCounters(t *testing.T) { gin.SetMode(gin.TestMode) collector := metrics.NewCollector( + "test-node", nil, &mockHub{}, &mockBus{pub: 100, del: 500}, diff --git a/server/main.go b/server/main.go index 1a01ece..150a85d 100644 --- a/server/main.go +++ b/server/main.go @@ -201,15 +201,18 @@ func main() { // ── WebSocket Hub ───────────────────────── hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg)) + // ── Node Identity ──────────────── + // Used by cluster registry (PG) and metrics endpoint (all deployments). + nodeID := cfg.ClusterNodeID + if nodeID == "" { + hostname, _ := os.Hostname() + nodeID = fmt.Sprintf("%s-%d", hostname, os.Getpid()) + } + // ── Cluster Registry ──────────── // PG-backed node self-registration + heartbeat. No-op on SQLite. var clusterReg *cluster.Registry if database.IsPostgres() && stores.Cluster != nil { - nodeID := cfg.ClusterNodeID - if nodeID == "" { - hostname, _ := os.Hostname() - nodeID = fmt.Sprintf("%s-%d", hostname, os.Getpid()) - } clusterReg = cluster.NewRegistry(nodeID, cfg.ClusterEndpoint, stores.Cluster, hub, cluster.RegistryConfig{ HeartbeatInterval: cfg.ClusterHeartbeatInterval, StaleThreshold: cfg.ClusterStaleThreshold, @@ -811,7 +814,7 @@ func main() { // ── Metrics ───────────────── metricsCollector := metrics.NewCollector( - database.DB, hub, bus, stores, + nodeID, database.DB, hub, bus, stores, sandbox.SandboxStats, triggerEngine.FireCount, startTime, diff --git a/server/metrics/collector.go b/server/metrics/collector.go index e7896d6..d9ffd03 100644 --- a/server/metrics/collector.go +++ b/server/metrics/collector.go @@ -18,6 +18,7 @@ import ( // Snapshot is the top-level JSON response from GET /api/v1/admin/metrics. type Snapshot struct { + NodeID string `json:"node_id"` Runtime RuntimeMetrics `json:"runtime"` DB DBMetrics `json:"db"` Cluster *ClusterMetrics `json:"cluster,omitempty"` @@ -91,6 +92,7 @@ type TriggerFireCountFunc func() int64 // Collector gathers metrics on demand for the admin endpoint. type Collector struct { + nodeID string db *sql.DB hub ConnCounter bus BusCounter @@ -101,8 +103,9 @@ type Collector struct { } // NewCollector creates a metrics collector with all required dependencies. -func NewCollector(db *sql.DB, hub ConnCounter, bus BusCounter, stores store.Stores, sandboxFn SandboxStatsFunc, triggerFn TriggerFireCountFunc, startTime time.Time) *Collector { +func NewCollector(nodeID string, db *sql.DB, hub ConnCounter, bus BusCounter, stores store.Stores, sandboxFn SandboxStatsFunc, triggerFn TriggerFireCountFunc, startTime time.Time) *Collector { return &Collector{ + nodeID: nodeID, db: db, hub: hub, bus: bus, @@ -115,7 +118,7 @@ func NewCollector(db *sql.DB, hub ConnCounter, bus BusCounter, stores store.Stor // Collect gathers all metrics synchronously and returns a snapshot. func (c *Collector) Collect(ctx context.Context) *Snapshot { - snap := &Snapshot{} + snap := &Snapshot{NodeID: c.nodeID} snap.Runtime = c.collectRuntime() snap.DB = c.collectDB(ctx) snap.Cluster = c.collectCluster(ctx) diff --git a/src/js/sw/surfaces/admin/health.js b/src/js/sw/surfaces/admin/health.js index aedf441..d340566 100644 --- a/src/js/sw/surfaces/admin/health.js +++ b/src/js/sw/surfaces/admin/health.js @@ -121,13 +121,13 @@ function NodeCard({ node }) { const ageColor = node.heartbeat_age_ms < 15000 ? 'var(--accent, #2a6)' : 'var(--warning, #c80)'; return html` -
-
- ${node.node_id} - ${ageLabel} +
+
+ ${node.node_id} + ${ageLabel}
- ${node.endpoint && html`
${node.endpoint}
`} -
+ ${node.endpoint && html`
${node.endpoint}
`} +
<${Stat} label="Uptime" value=${formatDuration(stats.uptime_sec)} /> <${Stat} label="WS Clients" value=${formatNum(stats.ws_clients)} /> <${Stat} label="Goroutines" value=${formatNum(stats.goroutines)} /> @@ -188,8 +188,9 @@ export default function HealthSection() { return html`
-
- Refresh +
+ ${data.node_id && html`Viewing from ${data.node_id}`} + Refresh