Fix health tab: node identity indicator + cluster card overflow
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 2m46s
CI/CD / test-sqlite (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m42s
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 2m46s
CI/CD / test-sqlite (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m42s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -121,13 +121,13 @@ function NodeCard({ node }) {
|
||||
const ageColor = node.heartbeat_age_ms < 15000 ? 'var(--accent, #2a6)' : 'var(--warning, #c80)';
|
||||
|
||||
return html`
|
||||
<div style="border:1px solid var(--border, #ddd);border-radius:8px;padding:12px;background:var(--bg-primary, #fff);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="font-size:13px;">${node.node_id}</strong>
|
||||
<span style="font-size:11px;color:${ageColor};font-weight:600;">${ageLabel}</span>
|
||||
<div style="border:1px solid var(--border, #ddd);border-radius:8px;padding:12px;background:var(--bg-primary, #fff);overflow:hidden;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;gap:8px;">
|
||||
<strong style="font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;" title=${node.node_id}>${node.node_id}</strong>
|
||||
<span style="font-size:11px;color:${ageColor};font-weight:600;flex-shrink:0;">${ageLabel}</span>
|
||||
</div>
|
||||
${node.endpoint && html`<div style="font-size:11px;color:var(--text-muted, #888);margin-bottom:8px;">${node.endpoint}</div>`}
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;">
|
||||
${node.endpoint && html`<div style="font-size:11px;color:var(--text-muted, #888);margin-bottom:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${node.endpoint}</div>`}
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;overflow:hidden;">
|
||||
<${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`
|
||||
<div>
|
||||
<div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:16px;gap:8px;">
|
||||
<span style="font-size:11px;color:var(--text-muted, #888);">Refresh</span>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;gap:8px;">
|
||||
${data.node_id && html`<span style="font-size:11px;color:var(--text-muted, #888);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;" title=${data.node_id}>Viewing from <strong style="color:var(--text-primary, #ccc);">${data.node_id}</strong></span>`}
|
||||
<span style="font-size:11px;color:var(--text-muted, #888);flex-shrink:0;">Refresh</span>
|
||||
<select style="font-size:12px;padding:2px 6px;" value=${interval} onChange=${e => setInterval_(+e.target.value)}>
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
|
||||
Reference in New Issue
Block a user