// Package monitor implements the local HTTP server that // HarborForge.Monitor (a separate native daemon on the same host) // queries for telemetry. Mirrors the OpenclawPlugin monitor-bridge: // // GET /telemetry → JSON Snapshot // GET /health → {"ok":true} // // The bridge runs as a goroutine started at plugin Init time; ctx // cancellation tears it down. Bind address is 127.0.0.1 only — // HarborForge.Monitor is expected on the same host. Configured port // comes from config.json's monitor_port; zero/missing disables the // bridge entirely (the plugin still serves tools + calendar). package monitor import ( "context" "encoding/json" "fmt" "net" "net/http" "sync" "time" "git.hangman-lab.top/zhi/HarborForge.PlexumPlugin/internal/telemetry" ) // Bridge owns the HTTP listener + last-query state. type Bridge struct { port int collect func() telemetry.Snapshot log LogFunc mu sync.Mutex lastQuery time.Time lastSnap telemetry.Snapshot queries uint64 server *http.Server } // LogFunc is the plugin's log adapter — kept narrow so the bridge // doesn't depend on the SDK directly. type LogFunc func(level, msg string, attrs map[string]any) // New constructs an idle Bridge. Call Start to actually listen. func New(port int, collect func() telemetry.Snapshot, log LogFunc) *Bridge { return &Bridge{port: port, collect: collect, log: log} } // Start the HTTP server in a background goroutine. Returns nil even // when the port is 0 (bridge disabled by config) so callers don't // need to guard. Stop cancels via context. func (b *Bridge) Start(ctx context.Context) error { if b.port <= 0 { b.log("info", "monitor bridge disabled (monitor_port=0)", nil) return nil } addr := fmt.Sprintf("127.0.0.1:%d", b.port) ln, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("monitor bridge listen %s: %w", addr, err) } mux := http.NewServeMux() mux.HandleFunc("/telemetry", b.handleTelemetry) mux.HandleFunc("/health", b.handleHealth) srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} b.server = srv go func() { <-ctx.Done() shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(shutCtx) }() b.log("info", "monitor bridge listening", map[string]any{"addr": addr}) go func() { if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { b.log("warn", "monitor bridge exited", map[string]any{"err": err.Error()}) } }() return nil } func (b *Bridge) handleTelemetry(w http.ResponseWriter, _ *http.Request) { snap := b.collect() b.mu.Lock() b.lastQuery = time.Now() b.lastSnap = snap b.queries++ b.mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(snap) } func (b *Bridge) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } // Stats returns a copy of the bridge's last-query state — used by the // harborforge_status / harborforge_monitor_telemetry tools. func (b *Bridge) Stats() Stats { b.mu.Lock() defer b.mu.Unlock() return Stats{ Port: b.port, Listening: b.server != nil, LastQuery: b.lastQuery, LastSnap: b.lastSnap, Queries: b.queries, } } // Stats is the introspection shape returned by Bridge.Stats. type Stats struct { Port int Listening bool LastQuery time.Time LastSnap telemetry.Snapshot Queries uint64 }