diff --git a/main.go b/main.go index 0e4ac69..d696f05 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "bytes" + "context" "database/sql" "embed" "encoding/json" @@ -11,10 +13,12 @@ import ( "math" "net/http" "os" + "os/signal" "path/filepath" "strconv" "strings" "sync" + "syscall" "time" _ "github.com/mattn/go-sqlite3" @@ -25,6 +29,12 @@ import ( //go:embed static var staticFiles embed.FS +const version = "0.7.1" + +// --------------------------------------------------------------------------- +// Config structs +// --------------------------------------------------------------------------- + type Config struct { Server ServerConfig `yaml:"server"` PLC PLCConfig `yaml:"plc"` @@ -66,8 +76,11 @@ type TrendConfig struct { Minutes int `yaml:"minutes"` } +// PressConfig: Go field is MaxTonnage (idiomatic). YAML tag kept as MAX_TONNAGE +// so existing config files need no changes. LegacyMaxTonnage handles old +// configs that used the lowercase "max_tonnage" key. type PressConfig struct { - MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"` + MaxTonnage float64 `yaml:"MAX_TONNAGE"` LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"` } @@ -88,6 +101,7 @@ type DBConfig struct { RetentionDays int `yaml:"retention_days"` MaxChartPoints int `yaml:"max_chart_points"` WriterQueueSize int `yaml:"writer_queue_size"` + AlarmQueueSize int `yaml:"alarm_queue_size"` CheckpointPages int `yaml:"checkpoint_pages"` CleanupIntervalHr int `yaml:"cleanup_interval_hours"` } @@ -118,7 +132,7 @@ func defaultConfig() Config { Minutes: 5, }, Press: PressConfig{ - MAX_TONNAGE: 64, + MaxTonnage: 64, }, UI: UIConfig{ Title: "Force Monitor", @@ -136,38 +150,48 @@ func defaultConfig() Config { RetentionDays: 30, MaxChartPoints: 2000, WriterQueueSize: 4096, + AlarmQueueSize: 512, CheckpointPages: 1000, CleanupIntervalHr: 6, }, } } +// --------------------------------------------------------------------------- +// Config normalisation helpers — eliminate repetitive if-chains +// --------------------------------------------------------------------------- + +func setIfZeroF(dst *float64, def float64) { + if *dst <= 0 { + *dst = def + } +} + +func setIfZeroI(dst *int, def int) { + if *dst <= 0 { + *dst = def + } +} + +func setIfEmpty(dst *string, def string) { + if strings.TrimSpace(*dst) == "" { + *dst = def + } +} + func normalizeConfig(cfg *Config) { def := defaultConfig() - if strings.TrimSpace(cfg.Server.ListenAddr) == "" { - cfg.Server.ListenAddr = def.Server.ListenAddr - } + setIfEmpty(&cfg.Server.ListenAddr, def.Server.ListenAddr) - if strings.TrimSpace(cfg.PLC.IP) == "" { - cfg.PLC.IP = def.PLC.IP - } - if cfg.PLC.DBNum <= 0 { - cfg.PLC.DBNum = def.PLC.DBNum - } - if cfg.PLC.PollMs <= 0 { - cfg.PLC.PollMs = def.PLC.PollMs - } - if cfg.PLC.ConnectTimeoutSec <= 0 { - cfg.PLC.ConnectTimeoutSec = def.PLC.ConnectTimeoutSec - } - if cfg.PLC.IdleTimeoutSec <= 0 { - cfg.PLC.IdleTimeoutSec = def.PLC.IdleTimeoutSec - } - if cfg.PLC.ReconnectDelaySec <= 0 { - cfg.PLC.ReconnectDelaySec = def.PLC.ReconnectDelaySec - } + setIfEmpty(&cfg.PLC.IP, def.PLC.IP) + setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum) + setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs) + setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec) + setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec) + setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec) + // Legacy threshold key migration (warning_kn / critical_kn / max_kn) if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 { cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn } @@ -178,82 +202,41 @@ func normalizeConfig(cfg *Config) { cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn } - if cfg.Thresholds.WarningPercent <= 0 { - cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent - } - if cfg.Thresholds.CriticalPercent <= 0 { - cfg.Thresholds.CriticalPercent = def.Thresholds.CriticalPercent - } - if cfg.Thresholds.GaugeMaxPercent <= 0 { - cfg.Thresholds.GaugeMaxPercent = def.Thresholds.GaugeMaxPercent - } - if cfg.Thresholds.ImbalanceWarningPercent <= 0 { - cfg.Thresholds.ImbalanceWarningPercent = def.Thresholds.ImbalanceWarningPercent - } - if cfg.Thresholds.ImbalanceCriticalPercent <= 0 { - cfg.Thresholds.ImbalanceCriticalPercent = def.Thresholds.ImbalanceCriticalPercent - } + setIfZeroF(&cfg.Thresholds.WarningPercent, def.Thresholds.WarningPercent) + setIfZeroF(&cfg.Thresholds.CriticalPercent, def.Thresholds.CriticalPercent) + setIfZeroF(&cfg.Thresholds.GaugeMaxPercent, def.Thresholds.GaugeMaxPercent) + setIfZeroF(&cfg.Thresholds.ImbalanceWarningPercent, def.Thresholds.ImbalanceWarningPercent) + setIfZeroF(&cfg.Thresholds.ImbalanceCriticalPercent, def.Thresholds.ImbalanceCriticalPercent) + if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent { cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent } - if cfg.Trend.Minutes <= 0 { - cfg.Trend.Minutes = def.Trend.Minutes - } + setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes) - if cfg.Press.MAX_TONNAGE <= 0 && cfg.Press.LegacyMaxTonnage > 0 { - cfg.Press.MAX_TONNAGE = cfg.Press.LegacyMaxTonnage - } - if cfg.Press.MAX_TONNAGE <= 0 { - cfg.Press.MAX_TONNAGE = def.Press.MAX_TONNAGE + // Legacy press tonnage key migration (max_tonnage lowercase) + if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 { + cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage } + setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage) - if strings.TrimSpace(cfg.UI.Title) == "" { - cfg.UI.Title = def.UI.Title - } - if strings.TrimSpace(cfg.UI.Subtitle) == "" { - cfg.UI.Subtitle = def.UI.Subtitle - } - if strings.TrimSpace(cfg.UI.LeftLabel) == "" { - cfg.UI.LeftLabel = def.UI.LeftLabel - } - if strings.TrimSpace(cfg.UI.RightLabel) == "" { - cfg.UI.RightLabel = def.UI.RightLabel - } - if strings.TrimSpace(cfg.UI.UnitForce) == "" { - cfg.UI.UnitForce = def.UI.UnitForce - } - if strings.TrimSpace(cfg.UI.UnitPct) == "" { - cfg.UI.UnitPct = def.UI.UnitPct - } + setIfEmpty(&cfg.UI.Title, def.UI.Title) + setIfEmpty(&cfg.UI.Subtitle, def.UI.Subtitle) + setIfEmpty(&cfg.UI.LeftLabel, def.UI.LeftLabel) + setIfEmpty(&cfg.UI.RightLabel, def.UI.RightLabel) + setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce) + setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct) - if strings.TrimSpace(cfg.DB.Path) == "" { - cfg.DB.Path = def.DB.Path - } - if cfg.DB.BusyTimeoutMs <= 0 { - cfg.DB.BusyTimeoutMs = def.DB.BusyTimeoutMs - } - if cfg.DB.BatchSize <= 0 { - cfg.DB.BatchSize = def.DB.BatchSize - } - if cfg.DB.FlushIntervalMs <= 0 { - cfg.DB.FlushIntervalMs = def.DB.FlushIntervalMs - } - if cfg.DB.RetentionDays <= 0 { - cfg.DB.RetentionDays = def.DB.RetentionDays - } - if cfg.DB.MaxChartPoints <= 0 { - cfg.DB.MaxChartPoints = def.DB.MaxChartPoints - } - if cfg.DB.WriterQueueSize <= 0 { - cfg.DB.WriterQueueSize = def.DB.WriterQueueSize - } - if cfg.DB.CheckpointPages <= 0 { - cfg.DB.CheckpointPages = def.DB.CheckpointPages - } - if cfg.DB.CleanupIntervalHr <= 0 { - cfg.DB.CleanupIntervalHr = def.DB.CleanupIntervalHr - } + setIfEmpty(&cfg.DB.Path, def.DB.Path) + setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs) + setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize) + setIfZeroI(&cfg.DB.FlushIntervalMs, def.DB.FlushIntervalMs) + setIfZeroI(&cfg.DB.RetentionDays, def.DB.RetentionDays) + setIfZeroI(&cfg.DB.MaxChartPoints, def.DB.MaxChartPoints) + setIfZeroI(&cfg.DB.WriterQueueSize, def.DB.WriterQueueSize) + setIfZeroI(&cfg.DB.AlarmQueueSize, def.DB.AlarmQueueSize) + setIfZeroI(&cfg.DB.CheckpointPages, def.DB.CheckpointPages) + setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr) } func loadOrCreateConfig(configPath string) (Config, error) { @@ -287,6 +270,10 @@ func loadOrCreateConfig(configPath string) (Config, error) { return cfg, nil } +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + type Sample struct { TS time.Time SilaLPct float32 @@ -431,6 +418,10 @@ type AlarmTracker struct { ImbZone string } +// --------------------------------------------------------------------------- +// Package-level singletons +// --------------------------------------------------------------------------- + var ( cfg Config state AppState @@ -439,20 +430,29 @@ var ( alarmCh chan AlarmEvent alarmTracker AlarmTracker uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) + cachedUI []byte // pre-rendered template (PageData is immutable after startup) ) -func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) { +// --------------------------------------------------------------------------- +// Force calculation — accepts maxTonnage explicitly (testable, no global dep) +// --------------------------------------------------------------------------- + +func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) { lp := float64(leftPercent) rp := float64(rightPercent) sumPct := (lp + rp) / 2.0 - left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) - right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) - total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE + left := (lp / 100.0) * (maxTonnage / 2.0) + right := (rp / 100.0) * (maxTonnage / 2.0) + total := (sumPct / 100.0) * maxTonnage return float32(left), float32(right), float32(sumPct), float32(total) } +// --------------------------------------------------------------------------- +// State helpers +// --------------------------------------------------------------------------- + func snapshotState() APIState { state.RLock() defer state.RUnlock() @@ -505,6 +505,13 @@ func enqueueAlarm(a AlarmEvent) { } } +// --------------------------------------------------------------------------- +// Database initialisation +// --------------------------------------------------------------------------- + +// ensureColumn adds a column to tableName if it does not already exist. +// NOTE: tableName and columnName are always hardcoded call-site constants — +// never derived from user input — so fmt.Sprintf is safe here. func ensureColumn(database *sql.DB, tableName, columnName, definition string) error { rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) if err != nil { @@ -515,8 +522,7 @@ func ensureColumn(database *sql.DB, tableName, columnName, definition string) er found := false for rows.Next() { var cid int - var name string - var ctype string + var name, ctype string var notNull int var dfltValue sql.NullString var pk int @@ -639,7 +645,11 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n return database, nil } -func startDBWriter(database *sql.DB) { +// --------------------------------------------------------------------------- +// DB writer goroutines — both now respect config values and context shutdown +// --------------------------------------------------------------------------- + +func startDBWriter(ctx context.Context, database *sql.DB) { ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond) defer ticker.Stop() @@ -705,6 +715,17 @@ func startDBWriter(database *sql.DB) { for { select { + case <-ctx.Done(): + // Drain any remaining queued samples before exit + for { + select { + case s := <-sampleCh: + batch = append(batch, s) + default: + flush() + return + } + } case s := <-sampleCh: batch = append(batch, s) if len(batch) >= cfg.DB.BatchSize { @@ -716,11 +737,12 @@ func startDBWriter(database *sql.DB) { } } -func startAlarmWriter(database *sql.DB) { - ticker := time.NewTicker(1000 * time.Millisecond) +func startAlarmWriter(ctx context.Context, database *sql.DB) { + // BUG FIX: was hardcoded 1000ms / 32 — now uses the same config values as startDBWriter + ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond) defer ticker.Stop() - batch := make([]AlarmEvent, 0, 32) + batch := make([]AlarmEvent, 0, cfg.DB.BatchSize) flush := func() { if len(batch) == 0 { @@ -780,9 +802,20 @@ func startAlarmWriter(database *sql.DB) { for { select { + case <-ctx.Done(): + // Drain remaining alarm events before exit + for { + select { + case a := <-alarmCh: + batch = append(batch, a) + default: + flush() + return + } + } case a := <-alarmCh: batch = append(batch, a) - if len(batch) >= 32 { + if len(batch) >= cfg.DB.BatchSize { flush() } case <-ticker.C: @@ -791,7 +824,7 @@ func startAlarmWriter(database *sql.DB) { } } -func startDBCleanup(database *sql.DB) { +func startDBCleanup(ctx context.Context, database *sql.DB) { if cfg.DB.RetentionDays <= 0 { return } @@ -811,11 +844,20 @@ func startDBCleanup(database *sql.DB) { cleanup() - for range ticker.C { - cleanup() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cleanup() + } } } +// --------------------------------------------------------------------------- +// Alarm zone helpers +// --------------------------------------------------------------------------- + func zoneFromValue(value float64, warn, crit float64) string { if value >= crit { return "critical" @@ -974,10 +1016,7 @@ func maybeLogPLCDisconnected(reason string) { alarmTracker.Lock() defer alarmTracker.Unlock() - if !alarmTracker.PLCKnown { - return - } - if !alarmTracker.PLCConnected { + if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected { return } @@ -998,8 +1037,22 @@ func maybeLogPLCDisconnected(reason string) { }) } -func startPLCPoller() { +// --------------------------------------------------------------------------- +// PLC poller +// --------------------------------------------------------------------------- + +func startPLCPoller(ctx context.Context) { + pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond + reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second + for { + // Check for shutdown before attempting a new connection + select { + case <-ctx.Done(): + return + 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 @@ -1007,7 +1060,11 @@ func startPLCPoller() { if err := handler.Connect(); err != nil { markDisconnected(err.Error()) log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec) - time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second) + select { + case <-ctx.Done(): + return + case <-time.After(reconnectDelay): + } continue } @@ -1016,8 +1073,18 @@ func startPLCPoller() { client := gos7.NewClient(handler) log.Println("PLC connected successfully") + // BUG FIX: buf was allocated inside the inner loop, causing a heap + // allocation every poll cycle. Moved outside — reused each iteration. + buf := make([]byte, 8) + for { - buf := make([]byte, 8) + select { + case <-ctx.Done(): + _ = handler.Close() + return + default: + } + if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil { log.Printf("PLC read error: %v - reconnecting...", err) markDisconnected(err.Error()) @@ -1029,7 +1096,7 @@ func startPLCPoller() { silaL := helper.GetRealAt(buf, 0) silaR := helper.GetRealAt(buf, 4) - leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR) + leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.Press.MaxTonnage) imbalance := float32(math.Abs(float64(silaL - silaR))) bias := silaL - silaR now := time.Now() @@ -1062,11 +1129,20 @@ func startPLCPoller() { evaluateProcessAlarms(sample) enqueueSample(sample) - time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) + select { + case <-ctx.Done(): + _ = handler.Close() + return + case <-time.After(pollInterval): + } } } } +// --------------------------------------------------------------------------- +// Query helpers +// --------------------------------------------------------------------------- + func parseWindow(raw string) (time.Duration, string, error) { s := strings.TrimSpace(strings.ToLower(raw)) if s == "" { @@ -1197,30 +1273,28 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) { return stats, nil } -func classifyForceDirection(delta float64, oldCount, newCount int) string { - if oldCount < 3 || newCount < 3 { - return "insufficient_data" - } - if math.Abs(delta) < 1.0 { - return "stable" - } - if delta > 0 { - return "rising" - } - return "falling" -} +// --------------------------------------------------------------------------- +// Trend / stability classification +// --------------------------------------------------------------------------- -func classifyImbalanceDirection(delta float64, oldCount, newCount int) string { +// classifyDirection is a single generic direction classifier that replaces +// the two near-identical classifyForceDirection / classifyImbalanceDirection +// functions that existed previously. +// +// stableThreshold — abs(delta) below this value → "stable" +// posLabel — label when delta > threshold (e.g. "rising", "worsening") +// negLabel — label when delta < -threshold (e.g. "falling", "improving") +func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string { if oldCount < 3 || newCount < 3 { return "insufficient_data" } - if math.Abs(delta) < 0.5 { + if math.Abs(delta) < stableThreshold { return "stable" } if delta > 0 { - return "worsening" + return posLabel } - return "improving" + return negLabel } func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) { @@ -1299,8 +1373,9 @@ func buildTrendResponse(window time.Duration, label string) (TrendResponse, erro forceDelta := forceNew.Avg - forceOld.Avg imbDelta := imbNew.Avg - imbOld.Avg - forceDirection := classifyForceDirection(forceDelta, forceOld.Count, forceNew.Count) - imbDirection := classifyImbalanceDirection(imbDelta, imbOld.Count, imbNew.Count) + + forceDirection := classifyDirection(forceDelta, forceOld.Count, forceNew.Count, 1.0, "rising", "falling") + imbDirection := classifyDirection(imbDelta, imbOld.Count, imbNew.Count, 0.5, "worsening", "improving") fullWindowForce, err := queryNumericStats("sum_pct", startNs, nowNs) if err != nil { @@ -1360,13 +1435,8 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) { events := make([]AlarmEventAPI, 0, limit) for rows.Next() { - var ts string - var severity string - var source string - var state string - var message string - var value float64 - var limitValue float64 + var ts, severity, source, state, message string + var value, limitValue float64 if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil { return nil, err @@ -1395,6 +1465,10 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) { return events, nil } +// --------------------------------------------------------------------------- +// HTTP handlers +// --------------------------------------------------------------------------- + func apiData(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") @@ -1464,7 +1538,15 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) { }) } +// serveUI serves the pre-rendered UI page. PageData is derived solely from +// the immutable config, so we render the template once at startup and reuse. func serveUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(cachedUI) +} + +// initCachedUI renders the HTML template once at startup. +func initCachedUI() { data := PageData{ Title: cfg.UI.Title, Subtitle: cfg.UI.Subtitle, @@ -1472,7 +1554,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) { RightLabel: cfg.UI.RightLabel, UnitForce: cfg.UI.UnitForce, UnitPct: cfg.UI.UnitPct, - MaxTonnage: cfg.Press.MAX_TONNAGE, + MaxTonnage: cfg.Press.MaxTonnage, WarningPercent: cfg.Thresholds.WarningPercent, CriticalPercent: cfg.Thresholds.CriticalPercent, GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, @@ -1483,13 +1565,17 @@ func serveUI(w http.ResponseWriter, r *http.Request) { DefaultTrendWindow: "15m", } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := uiTemplate.Execute(w, data); err != nil { - log.Printf("template execute error: %v", err) - http.Error(w, "render failed", http.StatusInternalServerError) + 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() } +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + func main() { wd, err := os.Getwd() if err != nil { @@ -1514,30 +1600,60 @@ func main() { defer db.Close() sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) - alarmCh = make(chan AlarmEvent, 512) + alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize) // BUG FIX: was hardcoded 512 - log.Printf("config loaded from: %s", configPath) - log.Printf("sqlite db: %s", dbPath) - log.Printf("PLC: ip=%s db=%d rack=%d slot=%d poll=%dms", + initCachedUI() + + log.Printf("S7-1200 Force Monitor v%s", version) + log.Printf("Config: %s", configPath) + log.Printf("DB: %s", dbPath) + log.Printf("PLC: ip=%s db=%d rack=%d slot=%d poll=%dms", cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs) - log.Printf("Press MAX_TONNAGE: %.2f %s", cfg.Press.MAX_TONNAGE, cfg.UI.UnitForce) + log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce) - go startDBWriter(db) - go startAlarmWriter(db) - go startDBCleanup(db) - go startPLCPoller() + // Graceful shutdown via SIGINT / SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() - http.Handle("/static/", http.FileServer(http.FS(staticFiles))) - http.HandleFunc("/", serveUI) - http.HandleFunc("/api/data", apiData) - http.HandleFunc("/api/history", apiHistory) - http.HandleFunc("/api/trend", apiTrend) - http.HandleFunc("/api/alarms", apiAlarms) + var wg sync.WaitGroup + wg.Add(4) + go func() { defer wg.Done(); startDBWriter(ctx, db) }() + go func() { defer wg.Done(); startAlarmWriter(ctx, db) }() + go func() { defer wg.Done(); startDBCleanup(ctx, db) }() + go func() { defer wg.Done(); startPLCPoller(ctx) }() - log.Println("S7-1200 Force Monitor started") - log.Println("VERSION 0.7.0") - log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) - log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) + mux := http.NewServeMux() + mux.Handle("/static/", http.FileServer(http.FS(staticFiles))) + mux.HandleFunc("/", serveUI) + mux.HandleFunc("/api/data", apiData) + mux.HandleFunc("/api/history", apiHistory) + mux.HandleFunc("/api/trend", apiTrend) + mux.HandleFunc("/api/alarms", apiAlarms) + + srv := &http.Server{ + Addr: cfg.Server.ListenAddr, + Handler: mux, + } + + log.Printf("Listening on http://localhost%s", cfg.Server.ListenAddr) + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + }() + + <-ctx.Done() + log.Println("Shutting down — flushing DB writers...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + wg.Wait() + log.Println("Shutdown complete") } const uiHTML = `