diff --git a/main.go b/main.go index b6fb4cc..1310020 100644 --- a/main.go +++ b/main.go @@ -16,12 +16,14 @@ import ( "os" "os/signal" "path/filepath" + "reflect" "strconv" "strings" "sync" "syscall" "time" + "github.com/fsnotify/fsnotify" _ "github.com/mattn/go-sqlite3" "github.com/robinson/gos7" "gopkg.in/yaml.v3" @@ -30,7 +32,7 @@ import ( //go:embed static var staticFiles embed.FS -const version = "0.8.2" +const version = "0.9.0" // --------------------------------------------------------------------------- // Config structs @@ -289,6 +291,24 @@ func normalizeConfig(cfg *Config) { setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr) } +func loadConfigStrict(configPath string) (Config, error) { + cfg := defaultConfig() + + data, err := os.ReadFile(configPath) + if err != nil { + return cfg, fmt.Errorf("failed to read config file: %w", err) + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&cfg); err != nil { + return cfg, fmt.Errorf("failed to parse config file: %w", err) + } + + normalizeConfig(&cfg) + return cfg, nil +} + func loadOrCreateConfig(configPath string) (Config, error) { cfg := defaultConfig() @@ -308,16 +328,32 @@ func loadOrCreateConfig(configPath string) (Config, error) { return cfg, fmt.Errorf("failed to stat config file: %w", err) } - data, err := os.ReadFile(configPath) - if err != nil { - return cfg, fmt.Errorf("failed to read config file: %w", err) - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - return cfg, fmt.Errorf("failed to parse config file: %w", err) - } + return loadConfigStrict(configPath) +} - normalizeConfig(&cfg) - return cfg, nil +func validateConfig(cfg Config) error { + if cfg.Thresholds.WarningPercent <= 0 { + return fmt.Errorf("thresholds.warning_percent must be > 0") + } + if cfg.Thresholds.CriticalPercent < cfg.Thresholds.WarningPercent { + return fmt.Errorf("thresholds.critical_percent must be >= thresholds.warning_percent") + } + if cfg.Thresholds.GaugeMaxPercent < cfg.Thresholds.CriticalPercent { + return fmt.Errorf("thresholds.gauge_max_percent must be >= thresholds.critical_percent") + } + if cfg.Thresholds.ImbalanceWarningPercent <= 0 { + return fmt.Errorf("thresholds.imbalance_warning_percent must be > 0") + } + if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent { + return fmt.Errorf("thresholds.imbalance_critical_percent must be >= thresholds.imbalance_warning_percent") + } + if cfg.Trend.Minutes <= 0 { + return fmt.Errorf("trend.minutes must be > 0") + } + if cfg.Press.MaxTonnage <= 0 { + return fmt.Errorf("press.MAX_TONNAGE must be > 0") + } + return nil } // --------------------------------------------------------------------------- @@ -484,6 +520,7 @@ type AlarmTracker struct { var ( cfg Config + cfgMu sync.RWMutex state AppState db *sql.DB sampleCh chan Sample @@ -509,6 +546,209 @@ func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (lef return float32(left), float32(right), float32(sumPct), float32(total) } +func getConfigSnapshot() Config { + cfgMu.RLock() + defer cfgMu.RUnlock() + return cfg +} + +func buildCachedUI(config Config) ([]byte, error) { + data := PageData{ + Title: config.UI.Title, + Subtitle: config.UI.Subtitle, + LeftLabel: config.UI.LeftLabel, + RightLabel: config.UI.RightLabel, + UnitForce: config.UI.UnitForce, + UnitPct: config.UI.UnitPct, + MaxTonnage: config.Press.MaxTonnage, + WarningPercent: config.Thresholds.WarningPercent, + CriticalPercent: config.Thresholds.CriticalPercent, + GaugeMaxPercent: config.Thresholds.GaugeMaxPercent, + ImbalanceWarningPercent: config.Thresholds.ImbalanceWarningPercent, + ImbalanceCriticalPercent: config.Thresholds.ImbalanceCriticalPercent, + PollMs: config.PLC.PollMs, + DefaultWindow: fmt.Sprintf("%dm", config.Trend.Minutes), + DefaultTrendWindow: "15m", + + ShowHeaderControls: boolValue(config.Modules.ShowHeaderControls, true), + ShowVerdict: boolValue(config.Modules.ShowVerdict, true), + ShowSummaryBar: boolValue(config.Modules.ShowSummaryBar, true), + ShowOverview: boolValue(config.Modules.ShowOverview, true), + ShowIntelligence: boolValue(config.Modules.ShowIntelligence, true), + ShowAlarmTimeline: boolValue(config.Modules.ShowAlarmTimeline, true), + ShowGauges: boolValue(config.Modules.ShowGauges, true), + ShowGaugeDigital: boolValue(config.Modules.ShowGaugeDigital, false), + ShowTrendChart: boolValue(config.Modules.ShowTrendChart, true), + } + + var buf bytes.Buffer + if err := uiTemplate.Execute(&buf, data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func initCachedUI() { + config := getConfigSnapshot() + payload, err := buildCachedUI(config) + if err != nil { + log.Fatalf("failed to pre-render UI template: %v", err) + } + + cfgMu.Lock() + cachedUI = payload + cfgMu.Unlock() +} + +func hotReloadSectionsLocked(dst *Config, src Config) { + dst.Thresholds = src.Thresholds + dst.Trend = src.Trend + dst.Press = src.Press + dst.UI = src.UI + dst.Modules = src.Modules +} + +func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartSections []string) { + if !reflect.DeepEqual(oldCfg.Thresholds, newCfg.Thresholds) { + hotSections = append(hotSections, "thresholds") + } + if !reflect.DeepEqual(oldCfg.Trend, newCfg.Trend) { + hotSections = append(hotSections, "trend") + } + if !reflect.DeepEqual(oldCfg.Press, newCfg.Press) { + hotSections = append(hotSections, "press") + } + if !reflect.DeepEqual(oldCfg.UI, newCfg.UI) { + hotSections = append(hotSections, "ui") + } + if !reflect.DeepEqual(oldCfg.Modules, newCfg.Modules) { + hotSections = append(hotSections, "modules") + } + + if !reflect.DeepEqual(oldCfg.Server, newCfg.Server) { + restartSections = append(restartSections, "server") + } + if !reflect.DeepEqual(oldCfg.PLC, newCfg.PLC) { + restartSections = append(restartSections, "plc") + } + if !reflect.DeepEqual(oldCfg.DB, newCfg.DB) { + restartSections = append(restartSections, "db") + } + + return hotSections, restartSections +} + +func reloadConfigSafely(configPath string) { + newCfg, err := loadConfigStrict(configPath) + if err != nil { + log.Printf("config reload rejected: %v", err) + return + } + if err := validateConfig(newCfg); err != nil { + log.Printf("config reload rejected: %v", err) + return + } + + oldCfg := getConfigSnapshot() + hotSections, restartSections := configSectionChanges(oldCfg, newCfg) + + updatedCfg := oldCfg + hotReloadSectionsLocked(&updatedCfg, newCfg) + + payload, err := buildCachedUI(updatedCfg) + if err != nil { + log.Printf("config reload rejected: failed to rebuild UI: %v", err) + return + } + + cfgMu.Lock() + cfg = updatedCfg + cachedUI = payload + cfgMu.Unlock() + + if len(hotSections) == 0 && len(restartSections) == 0 { + log.Printf("config reload checked: no effective changes") + return + } + if len(hotSections) > 0 { + log.Printf("config hot-reloaded safely: %s", strings.Join(hotSections, ", ")) + } + if len(restartSections) > 0 { + log.Printf("config changes detected in %s; restart required before they take effect", strings.Join(restartSections, ", ")) + } +} + +func startConfigWatcher(ctx context.Context, configPath string) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + dir := filepath.Dir(configPath) + target := filepath.Clean(configPath) + if err := watcher.Add(dir); err != nil { + _ = watcher.Close() + return err + } + + go func() { + defer watcher.Close() + + var ( + debounceTimer *time.Timer + debounceC <-chan time.Time + ) + + resetDebounce := func() { + if debounceTimer == nil { + debounceTimer = time.NewTimer(350 * time.Millisecond) + } else { + if !debounceTimer.Stop() { + select { + case <-debounceTimer.C: + default: + } + } + debounceTimer.Reset(350 * time.Millisecond) + } + debounceC = debounceTimer.C + } + + for { + select { + case <-ctx.Done(): + if debounceTimer != nil { + debounceTimer.Stop() + } + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if filepath.Clean(event.Name) != target { + continue + } + if event.Has(fsnotify.Chmod) { + continue + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) { + resetDebounce() + } + case <-debounceC: + debounceC = nil + reloadConfigSafely(configPath) + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("config watcher error: %v", err) + } + } + }() + + return nil +} + // --------------------------------------------------------------------------- // State helpers // --------------------------------------------------------------------------- @@ -938,20 +1178,21 @@ func sourceName(source string) string { } func sourceLimit(source, zone string) float64 { + config := getConfigSnapshot() switch source { case "imbalance": if zone == "critical" { - return cfg.Thresholds.ImbalanceCriticalPercent + return config.Thresholds.ImbalanceCriticalPercent } if zone == "warning" { - return cfg.Thresholds.ImbalanceWarningPercent + return config.Thresholds.ImbalanceWarningPercent } default: if zone == "critical" { - return cfg.Thresholds.CriticalPercent + return config.Thresholds.CriticalPercent } if zone == "warning" { - return cfg.Thresholds.WarningPercent + return config.Thresholds.WarningPercent } } return 0 @@ -1015,9 +1256,10 @@ func maybeLogZoneChange(source, prev, curr string, value float64) { } func evaluateProcessAlarms(s Sample) { - leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) - rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) - imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent) + config := getConfigSnapshot() + leftZone := zoneFromValue(float64(s.SilaLPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent) + rightZone := zoneFromValue(float64(s.SilaRPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent) + imbZone := zoneFromValue(float64(s.ImbalancePercent), config.Thresholds.ImbalanceWarningPercent, config.Thresholds.ImbalanceCriticalPercent) alarmTracker.Lock() defer alarmTracker.Unlock() @@ -1096,8 +1338,9 @@ func maybeLogPLCDisconnected(reason string) { // --------------------------------------------------------------------------- func startPLCPoller(ctx context.Context) { - pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond - reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second + bootCfg := getConfigSnapshot() + pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond + reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second for { select { @@ -1106,13 +1349,13 @@ func startPLCPoller(ctx context.Context) { default: } - handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot) - handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second - handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second + handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot) + handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second + handler.IdleTimeout = time.Duration(bootCfg.PLC.IdleTimeoutSec) * time.Second if err := handler.Connect(); err != nil { markDisconnected(err.Error()) - log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec) + log.Printf("PLC connect failed: %v - retrying in %ds...", err, bootCfg.PLC.ReconnectDelaySec) select { case <-ctx.Done(): return @@ -1136,7 +1379,7 @@ func startPLCPoller(ctx context.Context) { default: } - if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil { + if err := client.AGReadDB(bootCfg.PLC.DBNum, 0, 8, buf); err != nil { log.Printf("PLC read error: %v - reconnecting...", err) markDisconnected(err.Error()) _ = handler.Close() @@ -1147,7 +1390,7 @@ func startPLCPoller(ctx context.Context) { silaL := helper.GetRealAt(buf, 0) silaR := helper.GetRealAt(buf, 4) - leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.Press.MaxTonnage) + leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, getConfigSnapshot().Press.MaxTonnage) imbalance := float32(math.Abs(float64(silaL - silaR))) bias := silaL - silaR now := time.Now() @@ -1197,7 +1440,7 @@ func startPLCPoller(ctx context.Context) { func parseWindow(raw string) (time.Duration, string, error) { s := strings.TrimSpace(strings.ToLower(raw)) if s == "" { - s = fmt.Sprintf("%dm", cfg.Trend.Minutes) + s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes) } if strings.HasSuffix(s, "d") { @@ -1346,8 +1589,10 @@ func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sa return "insufficient_data", "Too few samples in selected trend window" } - if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 { - if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent { + config := getConfigSnapshot() + + if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= config.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 { + if avgImb5m >= config.Thresholds.ImbalanceCriticalPercent { return "unstable", "High average imbalance in last 5 minutes" } if math.Abs(forceDelta) >= 8.0 { @@ -1359,8 +1604,8 @@ func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sa return "unstable", "Imbalance variation is too high" } - if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 { - if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent { + if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 { + if avgImb5m >= config.Thresholds.ImbalanceWarningPercent { return "caution", "Imbalance is trending above warning region" } if math.Abs(forceDelta) >= 3.0 { @@ -1583,44 +1828,12 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) { } func serveUI(w http.ResponseWriter, r *http.Request) { + cfgMu.RLock() + payload := cachedUI + cfgMu.RUnlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(cachedUI) -} - -func initCachedUI() { - data := PageData{ - Title: cfg.UI.Title, - Subtitle: cfg.UI.Subtitle, - LeftLabel: cfg.UI.LeftLabel, - RightLabel: cfg.UI.RightLabel, - UnitForce: cfg.UI.UnitForce, - UnitPct: cfg.UI.UnitPct, - MaxTonnage: cfg.Press.MaxTonnage, - WarningPercent: cfg.Thresholds.WarningPercent, - CriticalPercent: cfg.Thresholds.CriticalPercent, - GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, - ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent, - ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent, - PollMs: cfg.PLC.PollMs, - DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes), - DefaultTrendWindow: "15m", - - ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true), - ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true), - ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true), - ShowOverview: boolValue(cfg.Modules.ShowOverview, true), - ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true), - ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true), - ShowGauges: boolValue(cfg.Modules.ShowGauges, true), - ShowGaugeDigital: boolValue(cfg.Modules.ShowGaugeDigital, false), - ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true), - } - - var buf bytes.Buffer - if err := uiTemplate.Execute(&buf, data); err != nil { - log.Fatalf("failed to pre-render UI template: %v", err) - } - cachedUI = buf.Bytes() + _, _ = w.Write(payload) } // --------------------------------------------------------------------------- @@ -1638,6 +1851,9 @@ func main() { if err != nil { log.Fatalf("failed to load config: %v", err) } + if err := validateConfig(cfg); err != nil { + log.Fatalf("invalid config: %v", err) + } dbPath := cfg.DB.Path if !filepath.IsAbs(dbPath) { @@ -1665,6 +1881,12 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + if err := startConfigWatcher(ctx, configPath); err != nil { + log.Printf("config watch disabled: %v", err) + } else { + log.Printf("Config watcher enabled for %s", configPath) + } + var wg sync.WaitGroup wg.Add(4) go func() { defer wg.Done(); startDBWriter(ctx, db) }() @@ -2346,7 +2568,7 @@ const uiHTML = ` {{end}} {{if .ShowGauges}} -
+
{{if .ShowGaugeDigital}}