Feat v0.6.4 health metrics #39

Merged
xcaliber merged 2 commits from feat/v0.6.4-health-metrics into main 2026-03-31 14:05:49 +00:00
4 changed files with 31 additions and 16 deletions
Showing only changes of commit 349ff5c80a - Show all commits

View File

@@ -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},

View File

@@ -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,

View File

@@ -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)

View File

@@ -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>