v9.0 - added auto reload at yaml change

This commit is contained in:
Dejan R 2026-04-19 12:33:14 +02:00
parent 15e390b693
commit 8d6076d046

358
main.go
View file

@ -16,12 +16,14 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
"github.com/fsnotify/fsnotify"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/robinson/gos7" "github.com/robinson/gos7"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -30,7 +32,7 @@ import (
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
const version = "0.8.2" const version = "0.9.0"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Config structs // Config structs
@ -289,6 +291,24 @@ func normalizeConfig(cfg *Config) {
setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr) 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) { func loadOrCreateConfig(configPath string) (Config, error) {
cfg := defaultConfig() cfg := defaultConfig()
@ -308,16 +328,32 @@ func loadOrCreateConfig(configPath string) (Config, error) {
return cfg, fmt.Errorf("failed to stat config file: %w", err) return cfg, fmt.Errorf("failed to stat config file: %w", err)
} }
data, err := os.ReadFile(configPath) return loadConfigStrict(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)
}
normalizeConfig(&cfg) func validateConfig(cfg Config) error {
return cfg, nil 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 ( var (
cfg Config cfg Config
cfgMu sync.RWMutex
state AppState state AppState
db *sql.DB db *sql.DB
sampleCh chan Sample sampleCh chan Sample
@ -509,6 +546,209 @@ func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (lef
return float32(left), float32(right), float32(sumPct), float32(total) 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 // State helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -938,20 +1178,21 @@ func sourceName(source string) string {
} }
func sourceLimit(source, zone string) float64 { func sourceLimit(source, zone string) float64 {
config := getConfigSnapshot()
switch source { switch source {
case "imbalance": case "imbalance":
if zone == "critical" { if zone == "critical" {
return cfg.Thresholds.ImbalanceCriticalPercent return config.Thresholds.ImbalanceCriticalPercent
} }
if zone == "warning" { if zone == "warning" {
return cfg.Thresholds.ImbalanceWarningPercent return config.Thresholds.ImbalanceWarningPercent
} }
default: default:
if zone == "critical" { if zone == "critical" {
return cfg.Thresholds.CriticalPercent return config.Thresholds.CriticalPercent
} }
if zone == "warning" { if zone == "warning" {
return cfg.Thresholds.WarningPercent return config.Thresholds.WarningPercent
} }
} }
return 0 return 0
@ -1015,9 +1256,10 @@ func maybeLogZoneChange(source, prev, curr string, value float64) {
} }
func evaluateProcessAlarms(s Sample) { func evaluateProcessAlarms(s Sample) {
leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) config := getConfigSnapshot()
rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent) leftZone := zoneFromValue(float64(s.SilaLPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent) rightZone := zoneFromValue(float64(s.SilaRPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
imbZone := zoneFromValue(float64(s.ImbalancePercent), config.Thresholds.ImbalanceWarningPercent, config.Thresholds.ImbalanceCriticalPercent)
alarmTracker.Lock() alarmTracker.Lock()
defer alarmTracker.Unlock() defer alarmTracker.Unlock()
@ -1096,8 +1338,9 @@ func maybeLogPLCDisconnected(reason string) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func startPLCPoller(ctx context.Context) { func startPLCPoller(ctx context.Context) {
pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond bootCfg := getConfigSnapshot()
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond
reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second
for { for {
select { select {
@ -1106,13 +1349,13 @@ func startPLCPoller(ctx context.Context) {
default: default:
} }
handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot) handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second handler.IdleTimeout = time.Duration(bootCfg.PLC.IdleTimeoutSec) * time.Second
if err := handler.Connect(); err != nil { if err := handler.Connect(); err != nil {
markDisconnected(err.Error()) 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return return
@ -1136,7 +1379,7 @@ func startPLCPoller(ctx context.Context) {
default: 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) log.Printf("PLC read error: %v - reconnecting...", err)
markDisconnected(err.Error()) markDisconnected(err.Error())
_ = handler.Close() _ = handler.Close()
@ -1147,7 +1390,7 @@ func startPLCPoller(ctx context.Context) {
silaL := helper.GetRealAt(buf, 0) silaL := helper.GetRealAt(buf, 0)
silaR := helper.GetRealAt(buf, 4) 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))) imbalance := float32(math.Abs(float64(silaL - silaR)))
bias := silaL - silaR bias := silaL - silaR
now := time.Now() now := time.Now()
@ -1197,7 +1440,7 @@ func startPLCPoller(ctx context.Context) {
func parseWindow(raw string) (time.Duration, string, error) { func parseWindow(raw string) (time.Duration, string, error) {
s := strings.TrimSpace(strings.ToLower(raw)) s := strings.TrimSpace(strings.ToLower(raw))
if s == "" { if s == "" {
s = fmt.Sprintf("%dm", cfg.Trend.Minutes) s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes)
} }
if strings.HasSuffix(s, "d") { 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" 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 { config := getConfigSnapshot()
if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent {
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" return "unstable", "High average imbalance in last 5 minutes"
} }
if math.Abs(forceDelta) >= 8.0 { 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" return "unstable", "Imbalance variation is too high"
} }
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 { if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent { if avgImb5m >= config.Thresholds.ImbalanceWarningPercent {
return "caution", "Imbalance is trending above warning region" return "caution", "Imbalance is trending above warning region"
} }
if math.Abs(forceDelta) >= 3.0 { 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) { 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.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(cachedUI) _, _ = w.Write(payload)
}
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()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1638,6 +1851,9 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("failed to load config: %v", err) log.Fatalf("failed to load config: %v", err)
} }
if err := validateConfig(cfg); err != nil {
log.Fatalf("invalid config: %v", err)
}
dbPath := cfg.DB.Path dbPath := cfg.DB.Path
if !filepath.IsAbs(dbPath) { if !filepath.IsAbs(dbPath) {
@ -1665,6 +1881,12 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() 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 var wg sync.WaitGroup
wg.Add(4) wg.Add(4)
go func() { defer wg.Done(); startDBWriter(ctx, db) }() go func() { defer wg.Done(); startDBWriter(ctx, db) }()
@ -2346,7 +2568,7 @@ const uiHTML = `<!DOCTYPE html>
{{end}} {{end}}
{{if .ShowGauges}} {{if .ShowGauges}}
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-8 mb-8"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
<div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300"> <div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
{{if .ShowGaugeDigital}} {{if .ShowGaugeDigital}}
<div class="gauge-header-row"> <div class="gauge-header-row">