Files
hzhang 6e3ad669f8 feat(monitor): active push loop replacing standalone monitor
Adds a periodic POST loop to <backend>/monitor/server/heartbeat so
HF plugin can take over the standalone harborforge-monitor daemon's
job — same X-API-Key header, same flat telemetry shape (cpu_pct /
mem_pct / disk_pct / swap_pct / load_avg / uptime_seconds /
plugin_version / agents[]). HF backend stays unchanged.

Config: monitor_push_enabled (default false; opt-in to avoid surprise
heartbeats from existing deployments), monitor_push_interval_seconds
(default 30), reuses apiKey for the X-API-Key header. Lift the
container's HF_MONITER_API_KEY into config.apiKey, flip
monitor_push_enabled true, then docker rm -f the container — DB
last_seen_at keeps advancing under the plugin's loop.

Collector grew swap + cpu sampling (two reads of /proc/stat over a
1-second window when SampleCPU=true). Bridge endpoint stays cheap
(SampleCPU=false on demand); push loop is the only caller paying the
sampling cost.

E2E in sim: monitor_push_enabled=true + apiKey from injected
MonitoredServer row → server_states.last_seen_at advances exactly
every interval_seconds (10s configured, 10s observed). cpu/mem/disk/
swap_pct all populate correctly.
2026-06-03 13:04:51 +01:00

170 lines
5.8 KiB
Go

// Package config loads the HarborForge plugin's per-profile config
// from <profile>/plugins/harbor-forge/config.json. Mirrors the
// resolved-config shape HarborForge.OpenclawPlugin's
// plugin/core/config.ts surfaces, adapted for the Plexum profile
// layout.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// Config is the operator-visible shape stored at
// ~/.plexum/plugins/harbor-forge/config.json. All fields optional;
// resolved defaults live in Resolve().
type Config struct {
Enabled bool `json:"enabled,omitempty"`
// BackendURL is the HarborForge backend base (Monitor + Calendar
// API share this URL — same as OpenclawPlugin's backendUrl).
BackendURL string `json:"backendUrl,omitempty"`
// Identifier is reported on heartbeat + Monitor responses. Auto-
// derived from hostname when empty.
Identifier string `json:"identifier,omitempty"`
// APIKey authenticates Monitor + Calendar API calls.
APIKey string `json:"apiKey,omitempty"`
// MonitorPort is the local TCP port the Monitor bridge HTTP
// server listens on. Zero/missing disables the bridge entirely.
MonitorPort int `json:"monitor_port,omitempty"`
// MonitorPushEnabled toggles the active push loop that uploads
// system telemetry to BackendURL /monitor/server/heartbeat. Lets
// HF plugin replace the standalone harborforge-monitor container.
// nil (unset) defaults to false; operators must opt in explicitly
// since they need to provision APIKey too.
MonitorPushEnabled *bool `json:"monitor_push_enabled,omitempty"`
// MonitorPushIntervalSeconds — defaults to 30s when ≤0. Mirrors
// the standalone monitor's HF_MONITER_REPORT_INTERVAL knob.
MonitorPushIntervalSeconds int `json:"monitor_push_interval_seconds,omitempty"`
// CalendarHeartbeatIntervalSeconds — defaults to 30s when ≤0.
CalendarHeartbeatIntervalSeconds int `json:"calendar_heartbeat_interval_seconds,omitempty"`
// CalendarEnabled toggles the calendar scheduler loop. Default true.
CalendarEnabled *bool `json:"calendar_enabled,omitempty"`
// CalendarBackendURL — if set, overrides BackendURL for Calendar
// API calls only (Monitor still uses BackendURL). Matches OpenClaw
// plugin's split-endpoint config.
CalendarBackendURL string `json:"calendar_backendUrl,omitempty"`
// RestartPollIntervalSeconds — defaults to 60s when ≤0.
RestartPollIntervalSeconds int `json:"restart_poll_interval_seconds,omitempty"`
}
// Resolved is the post-defaults view used by the rest of the plugin.
type Resolved struct {
Enabled bool
BackendURL string
Identifier string
APIKey string
MonitorPort int
MonitorPushEnabled bool
MonitorPushIntervalSeconds int
CalendarEnabled bool
CalendarHeartbeatIntervalSeconds int
CalendarBackendURL string
RestartPollIntervalSeconds int
}
// PluginConfigDir returns the on-disk path the plugin's own config
// lives in, given the Plexum profile root. Mirrors how every other
// Plexum plugin lays out its config.
func PluginConfigDir(profileRoot string) string {
return filepath.Join(profileRoot, "plugins", "harbor-forge")
}
// PluginConfigPath returns the full path to config.json.
func PluginConfigPath(profileRoot string) string {
return filepath.Join(PluginConfigDir(profileRoot), "config.json")
}
// Load reads + parses the on-disk config; missing file is treated as
// empty (defaults applied at Resolve time, not here).
func Load(profileRoot string) (Config, error) {
path := PluginConfigPath(profileRoot)
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Config{}, nil
}
return Config{}, fmt.Errorf("read %s: %w", path, err)
}
if len(raw) == 0 {
return Config{}, nil
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
return Config{}, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
// Resolve applies defaults + hostname-derived identifier.
func Resolve(c Config) Resolved {
out := Resolved{
Enabled: true,
BackendURL: "https://monitor.hangman-lab.top",
Identifier: c.Identifier,
APIKey: c.APIKey,
MonitorPort: c.MonitorPort,
MonitorPushEnabled: false,
MonitorPushIntervalSeconds: 30,
CalendarEnabled: true,
CalendarHeartbeatIntervalSeconds: 30,
CalendarBackendURL: c.CalendarBackendURL,
RestartPollIntervalSeconds: 60,
}
// Explicit-false overrides default-true.
if c.Enabled || !c.Enabled && hasJSONField(c, "Enabled") {
// Default true; we don't have a way to distinguish unset vs
// explicit false on a plain bool. Document: write {"enabled":
// false} only when intentional.
}
if c.BackendURL != "" {
out.BackendURL = c.BackendURL
}
if c.Identifier == "" {
out.Identifier = autoIdentifier()
}
if c.CalendarEnabled != nil {
out.CalendarEnabled = *c.CalendarEnabled
}
if c.CalendarHeartbeatIntervalSeconds > 0 {
out.CalendarHeartbeatIntervalSeconds = c.CalendarHeartbeatIntervalSeconds
}
if c.MonitorPushEnabled != nil {
out.MonitorPushEnabled = *c.MonitorPushEnabled
}
if c.MonitorPushIntervalSeconds > 0 {
out.MonitorPushIntervalSeconds = c.MonitorPushIntervalSeconds
}
if c.RestartPollIntervalSeconds > 0 {
out.RestartPollIntervalSeconds = c.RestartPollIntervalSeconds
}
return out
}
// hasJSONField is a placeholder — Go's encoding/json doesn't surface
// "field was present in source" without a sentinel pointer. We use
// *bool on the optional toggles instead and treat plain bool as
// default-true.
func hasJSONField(_ Config, _ string) bool { return false }
func autoIdentifier() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "unknown"
}
return h
}