Feat v0.6.4 health metrics #39
@@ -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