mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-19 14:16:16 +00:00
## Summary Adds MQTT source status visibility per #1043 acceptance criteria: - **Ingestor:** per-source counter registry (`cmd/ingestor/source_status.go`) tracking `connected`, `lastConnectUnix`, `lastDisconnectUnix`, `lastPacketUnix`, `connectCount`, `disconnectCount`, `packetsTotal`, `packetsLast5m` (sliding 5-min window via per-second buckets keyed by unix second — no stale-leak), `lastError`. Wired at the existing OnConnect / ConnectionLost / DefaultPublish callsites alongside the liveness watchdog. Idempotent registration so counters survive reconnects. Snapshot emitted in the existing stats file under `source_statuses` (additive, `omitempty`). - **Backend:** new `GET /api/mqtt/status` handler reads the ingestor stats file and returns the per-source list. **Broker passwords are masked** via a regex over the `scheme://user:pass@host` form (covers mqtt/mqtts/tcp/ssl/ws/wss). Mask is also applied to `lastError` as defense-in-depth (broker libs occasionally quote the failing URL). OpenAPI completeness gate satisfied with a `routeDescriptions` entry. - **Frontend:** small self-contained panel (`public/mqtt-status-panel.js`) mounted above the Observers table. Auto-refreshes every 10s, color-codes each row (green = connected + recent packet, yellow = connected idle, red = disconnected), and tears down its timer on SPA route change. ## TDD - Red commit `f19a93b5` — stub `/api/mqtt/status` handler + assertion test that the broker password is `****`-redacted. Test fails on the assertion (handler passes the URL through verbatim). Compile-clean — assertion-fail, not build-fail. - Green commit `77042e41` — `maskBrokerURL` helper + table-driven unit tests across all schemes + handler rewires to mask both `Broker` and `LastError`. - Subsequent commits land the ingestor wiring and the frontend panel. ## Tests ``` $ cd cmd/server && go test -run 'TestMqttStatus|TestMaskBrokerURL' -v ./... PASS: TestMqttStatus_MasksBrokerPassword PASS: TestMqttStatus_EmptyWhenNoStatsFile PASS: TestMaskBrokerURL_Patterns (10 subtests) $ cd cmd/ingestor && go test -run 'TestSourceStatus|TestSnapshotSourceStatuses' -v ./... PASS: TestSourceStatus_BasicLifecycle PASS: TestSourceStatus_Disconnect PASS: TestSnapshotSourceStatuses_ReturnsAll $ node test-mqtt-status-panel.js 7 passed, 0 failed ``` Full `go test ./...` clean in both `cmd/server` and `cmd/ingestor`. ## Preflight overrides - `cross-stack`: justified — issue #1043 is intrinsically full-stack (ingestor stats → server endpoint → observers panel). Per-stack split would land an unreachable endpoint or a fetch with no backend. - `check-xss-sinks` (public/mqtt-status-panel.js:55): justified — the flagged `innerHTML=` is a fully-static literal (empty-state placeholder, no payload data interpolated). All payload-bearing `innerHTML=` sites in this file run through `escapeHTML` (defined in the same file); the test `renderPanel never echoes a plaintext password (defense-in-depth)` exercises the rendered HTML against payload strings. ## Acceptance criteria - [x] `/api/mqtt/status` returns per-source connection state — `cmd/server/mqtt_status.go` - [x] UI panel shows all configured sources with live status — `public/mqtt-status-panel.js` - [x] Connection state updates on reconnect/disconnect events — `MarkConnect` / `MarkDisconnect` wired in `cmd/ingestor/main.go` - [x] Broker URLs don't expose passwords in the API response — `maskBrokerURL` + 13 test cases - [x] Works with 1-N sources — registry is keyed per-source, snapshot iterates the map **Partial fix for #1043** — per-packet `mqtt_source` attribution (the issue's "Follow-up" section) is **deferred** per the `mc-bot-triaged:v1` triage and the autofix comment ("Per-packet attribution deferred to follow-up issue"). That work requires a new observation-row column and DB schema migration, both explicitly out of scope for this PR. Refs #1043 --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
188 lines
6.3 KiB
Go
188 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// SourceStatusSnapshot is the per-MQTT-source connection state and counter
|
|
// view written to the ingestor stats file (under "source_statuses") and
|
|
// consumed by cmd/server's /api/mqtt/status handler (#1043).
|
|
//
|
|
// All fields are unix seconds (0 = "never"). PacketsLast5m is a sliding
|
|
// 5-minute count derived from a per-second ring buffer.
|
|
type SourceStatusSnapshot struct {
|
|
Name string `json:"name"`
|
|
Broker string `json:"broker"`
|
|
Connected bool `json:"connected"`
|
|
LastConnectUnix int64 `json:"lastConnectUnix"`
|
|
LastDisconnectUnix int64 `json:"lastDisconnectUnix"`
|
|
LastPacketUnix int64 `json:"lastPacketUnix"`
|
|
ConnectCount int64 `json:"connectCount"`
|
|
DisconnectCount int64 `json:"disconnectCount"`
|
|
PacketsTotal int64 `json:"packetsTotal"`
|
|
PacketsLast5m int64 `json:"packetsLast5m"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
}
|
|
|
|
// sourceStatusState is the in-memory per-source counter set. All scalar
|
|
// fields are accessed via sync/atomic so the hot-path MarkPacket /
|
|
// MarkConnect / MarkDisconnect callsites stay lock-free. The 5-minute
|
|
// sliding window uses a 300-element per-second ring (one slot per
|
|
// second), guarded by ringMu only when we slide the cursor — the common
|
|
// path increments the current second with a single atomic.AddInt64.
|
|
//
|
|
// Memory: one state per source (typically 1-5 in production). 300 int64
|
|
// slots = 2.4KB/source — fine.
|
|
type sourceStatusState struct {
|
|
name string
|
|
broker string // raw broker URL — server-side handler masks the password
|
|
|
|
connected atomic.Bool
|
|
lastConnectUnix atomic.Int64
|
|
lastDisconnectUnix atomic.Int64
|
|
lastPacketUnix atomic.Int64
|
|
connectCount atomic.Int64
|
|
disconnectCount atomic.Int64
|
|
packetsTotal atomic.Int64
|
|
|
|
// 5-minute sliding window: per-second buckets keyed by unix second.
|
|
// Stored as parallel arrays so we can both zero-out a stale slot AND
|
|
// know whether a slot's contents are still inside the window.
|
|
ringMu sync.Mutex
|
|
ringSec [300]int64 // unix second this slot represents (0 = unused)
|
|
ringCount [300]int64 // packets received in that second
|
|
|
|
// lastError is rare-write/rare-read so a plain mutex is fine.
|
|
errMu sync.RWMutex
|
|
lastError string
|
|
}
|
|
|
|
// MarkConnect records a successful (re)connection to the broker.
|
|
// Clears any stale lastError from a prior disconnect — otherwise the UI
|
|
// shows "connected=true, lastError='connection refused'" after a successful
|
|
// reconnect, which is a lie (#1682 munger review r1).
|
|
func (s *sourceStatusState) MarkConnect(now time.Time) {
|
|
s.connected.Store(true)
|
|
s.lastConnectUnix.Store(now.Unix())
|
|
s.connectCount.Add(1)
|
|
s.errMu.Lock()
|
|
s.lastError = ""
|
|
s.errMu.Unlock()
|
|
}
|
|
|
|
// MarkDisconnect records the broker dropping the connection.
|
|
func (s *sourceStatusState) MarkDisconnect(now time.Time, err error) {
|
|
s.connected.Store(false)
|
|
s.lastDisconnectUnix.Store(now.Unix())
|
|
s.disconnectCount.Add(1)
|
|
if err != nil {
|
|
s.errMu.Lock()
|
|
s.lastError = err.Error()
|
|
s.errMu.Unlock()
|
|
}
|
|
}
|
|
|
|
// MarkPacket records receipt of an MQTT message. Hot path.
|
|
func (s *sourceStatusState) MarkPacket(now time.Time) {
|
|
nowSec := now.Unix()
|
|
s.lastPacketUnix.Store(nowSec)
|
|
s.packetsTotal.Add(1)
|
|
|
|
slot := nowSec % int64(len(s.ringSec))
|
|
s.ringMu.Lock()
|
|
if s.ringSec[slot] != nowSec {
|
|
s.ringSec[slot] = nowSec
|
|
s.ringCount[slot] = 0
|
|
}
|
|
s.ringCount[slot]++
|
|
s.ringMu.Unlock()
|
|
}
|
|
|
|
// sumLast5m returns the count of MarkPacket calls in the last 300s. Slots
|
|
// whose stored second falls outside the window are ignored (no stale leak).
|
|
func (s *sourceStatusState) sumLast5m(now time.Time) int64 {
|
|
nowSec := now.Unix()
|
|
cutoff := nowSec - int64(len(s.ringSec)) + 1
|
|
var total int64
|
|
s.ringMu.Lock()
|
|
for i := 0; i < len(s.ringSec); i++ {
|
|
if s.ringSec[i] >= cutoff && s.ringSec[i] <= nowSec {
|
|
total += s.ringCount[i]
|
|
}
|
|
}
|
|
s.ringMu.Unlock()
|
|
return total
|
|
}
|
|
|
|
// snapshot copies the state into a serializable view.
|
|
func (s *sourceStatusState) snapshot(now time.Time) SourceStatusSnapshot {
|
|
s.errMu.RLock()
|
|
errStr := s.lastError
|
|
s.errMu.RUnlock()
|
|
return SourceStatusSnapshot{
|
|
Name: s.name,
|
|
Broker: s.broker,
|
|
Connected: s.connected.Load(),
|
|
LastConnectUnix: s.lastConnectUnix.Load(),
|
|
LastDisconnectUnix: s.lastDisconnectUnix.Load(),
|
|
LastPacketUnix: s.lastPacketUnix.Load(),
|
|
ConnectCount: s.connectCount.Load(),
|
|
DisconnectCount: s.disconnectCount.Load(),
|
|
PacketsTotal: s.packetsTotal.Load(),
|
|
PacketsLast5m: s.sumLast5m(now),
|
|
LastError: errStr,
|
|
}
|
|
}
|
|
|
|
// sourceStatusRegistry holds one sourceStatusState per source. Keyed by
|
|
// tag (which is the source Name, or the Broker URL if the operator left
|
|
// the name blank).
|
|
var (
|
|
sourceStatusRegistryMu sync.RWMutex
|
|
sourceStatusRegistry = map[string]*sourceStatusState{}
|
|
)
|
|
|
|
// RegisterSourceStatus creates (or returns the existing) state for the
|
|
// given source. Safe for cold-start use; idempotent — re-registering the
|
|
// same tag returns the existing state so counters aren't reset across
|
|
// reconnects.
|
|
func RegisterSourceStatus(tag, broker string) *sourceStatusState {
|
|
sourceStatusRegistryMu.Lock()
|
|
defer sourceStatusRegistryMu.Unlock()
|
|
if s, ok := sourceStatusRegistry[tag]; ok {
|
|
return s
|
|
}
|
|
s := &sourceStatusState{name: tag, broker: broker}
|
|
sourceStatusRegistry[tag] = s
|
|
return s
|
|
}
|
|
|
|
// lookupSourceStatus returns the state for tag, or nil if unregistered.
|
|
func lookupSourceStatus(tag string) *sourceStatusState {
|
|
sourceStatusRegistryMu.RLock()
|
|
defer sourceStatusRegistryMu.RUnlock()
|
|
return sourceStatusRegistry[tag]
|
|
}
|
|
|
|
// SnapshotSourceStatuses returns a slice of every registered source's
|
|
// current snapshot. Surfaced via the ingestor stats file under
|
|
// "source_statuses" so /api/mqtt/status can serve it (#1043).
|
|
func SnapshotSourceStatuses(now time.Time) []SourceStatusSnapshot {
|
|
sourceStatusRegistryMu.RLock()
|
|
defer sourceStatusRegistryMu.RUnlock()
|
|
out := make([]SourceStatusSnapshot, 0, len(sourceStatusRegistry))
|
|
for _, s := range sourceStatusRegistry {
|
|
out = append(out, s.snapshot(now))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resetSourceStatusRegistry clears the registry. Test-only helper.
|
|
func resetSourceStatusRegistry() {
|
|
sourceStatusRegistryMu.Lock()
|
|
defer sourceStatusRegistryMu.Unlock()
|
|
sourceStatusRegistry = map[string]*sourceStatusState{}
|
|
}
|