Summary table

#SeverityIssue1–2🔴 BugstartAlarmWriter ignores config batch/flush settings3🔴 Perfbuf allocated in hot PLC loop4🟡 StyleMAX_TONNAGE breaks Go naming5🟡 DRYDuplicate direction classifiers6🟡 Perf8 sequential DB queries in trend handler7🟡 ReliabilityNo graceful shutdown8–11🟢 PolishMinor cleanup items
This commit is contained in:
Gamer 2026-04-17 17:44:57 +02:00
parent 3c9509169d
commit 8cd6e066e8

442
main.go
View file

@ -1,6 +1,8 @@
package main package main
import ( import (
"bytes"
"context"
"database/sql" "database/sql"
"embed" "embed"
"encoding/json" "encoding/json"
@ -11,10 +13,12 @@ import (
"math" "math"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -25,6 +29,12 @@ import (
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
const version = "0.7.1"
// ---------------------------------------------------------------------------
// Config structs
// ---------------------------------------------------------------------------
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
PLC PLCConfig `yaml:"plc"` PLC PLCConfig `yaml:"plc"`
@ -66,8 +76,11 @@ type TrendConfig struct {
Minutes int `yaml:"minutes"` 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 { type PressConfig struct {
MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"` MaxTonnage float64 `yaml:"MAX_TONNAGE"`
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"` LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
} }
@ -88,6 +101,7 @@ type DBConfig struct {
RetentionDays int `yaml:"retention_days"` RetentionDays int `yaml:"retention_days"`
MaxChartPoints int `yaml:"max_chart_points"` MaxChartPoints int `yaml:"max_chart_points"`
WriterQueueSize int `yaml:"writer_queue_size"` WriterQueueSize int `yaml:"writer_queue_size"`
AlarmQueueSize int `yaml:"alarm_queue_size"`
CheckpointPages int `yaml:"checkpoint_pages"` CheckpointPages int `yaml:"checkpoint_pages"`
CleanupIntervalHr int `yaml:"cleanup_interval_hours"` CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
} }
@ -118,7 +132,7 @@ func defaultConfig() Config {
Minutes: 5, Minutes: 5,
}, },
Press: PressConfig{ Press: PressConfig{
MAX_TONNAGE: 64, MaxTonnage: 64,
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Force Monitor", Title: "Force Monitor",
@ -136,38 +150,48 @@ func defaultConfig() Config {
RetentionDays: 30, RetentionDays: 30,
MaxChartPoints: 2000, MaxChartPoints: 2000,
WriterQueueSize: 4096, WriterQueueSize: 4096,
AlarmQueueSize: 512,
CheckpointPages: 1000, CheckpointPages: 1000,
CleanupIntervalHr: 6, 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) { func normalizeConfig(cfg *Config) {
def := defaultConfig() def := defaultConfig()
if strings.TrimSpace(cfg.Server.ListenAddr) == "" { setIfEmpty(&cfg.Server.ListenAddr, def.Server.ListenAddr)
cfg.Server.ListenAddr = def.Server.ListenAddr
}
if strings.TrimSpace(cfg.PLC.IP) == "" { setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
cfg.PLC.IP = def.PLC.IP setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
} setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
if cfg.PLC.DBNum <= 0 { setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
cfg.PLC.DBNum = def.PLC.DBNum setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
} setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)
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
}
// Legacy threshold key migration (warning_kn / critical_kn / max_kn)
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 { if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
} }
@ -178,82 +202,41 @@ func normalizeConfig(cfg *Config) {
cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
} }
if cfg.Thresholds.WarningPercent <= 0 { setIfZeroF(&cfg.Thresholds.WarningPercent, def.Thresholds.WarningPercent)
cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent setIfZeroF(&cfg.Thresholds.CriticalPercent, def.Thresholds.CriticalPercent)
} setIfZeroF(&cfg.Thresholds.GaugeMaxPercent, def.Thresholds.GaugeMaxPercent)
if cfg.Thresholds.CriticalPercent <= 0 { setIfZeroF(&cfg.Thresholds.ImbalanceWarningPercent, def.Thresholds.ImbalanceWarningPercent)
cfg.Thresholds.CriticalPercent = def.Thresholds.CriticalPercent setIfZeroF(&cfg.Thresholds.ImbalanceCriticalPercent, def.Thresholds.ImbalanceCriticalPercent)
}
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
}
if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent { if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent
} }
if cfg.Trend.Minutes <= 0 { setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
cfg.Trend.Minutes = def.Trend.Minutes
}
if cfg.Press.MAX_TONNAGE <= 0 && cfg.Press.LegacyMaxTonnage > 0 { // Legacy press tonnage key migration (max_tonnage lowercase)
cfg.Press.MAX_TONNAGE = cfg.Press.LegacyMaxTonnage if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
} cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
if cfg.Press.MAX_TONNAGE <= 0 {
cfg.Press.MAX_TONNAGE = def.Press.MAX_TONNAGE
} }
setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage)
if strings.TrimSpace(cfg.UI.Title) == "" { setIfEmpty(&cfg.UI.Title, def.UI.Title)
cfg.UI.Title = def.UI.Title setIfEmpty(&cfg.UI.Subtitle, def.UI.Subtitle)
} setIfEmpty(&cfg.UI.LeftLabel, def.UI.LeftLabel)
if strings.TrimSpace(cfg.UI.Subtitle) == "" { setIfEmpty(&cfg.UI.RightLabel, def.UI.RightLabel)
cfg.UI.Subtitle = def.UI.Subtitle setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
} setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)
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
}
if strings.TrimSpace(cfg.DB.Path) == "" { setIfEmpty(&cfg.DB.Path, def.DB.Path)
cfg.DB.Path = def.DB.Path setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
} setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
if cfg.DB.BusyTimeoutMs <= 0 { setIfZeroI(&cfg.DB.FlushIntervalMs, def.DB.FlushIntervalMs)
cfg.DB.BusyTimeoutMs = def.DB.BusyTimeoutMs setIfZeroI(&cfg.DB.RetentionDays, def.DB.RetentionDays)
} setIfZeroI(&cfg.DB.MaxChartPoints, def.DB.MaxChartPoints)
if cfg.DB.BatchSize <= 0 { setIfZeroI(&cfg.DB.WriterQueueSize, def.DB.WriterQueueSize)
cfg.DB.BatchSize = def.DB.BatchSize setIfZeroI(&cfg.DB.AlarmQueueSize, def.DB.AlarmQueueSize)
} setIfZeroI(&cfg.DB.CheckpointPages, def.DB.CheckpointPages)
if cfg.DB.FlushIntervalMs <= 0 { setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr)
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
}
} }
func loadOrCreateConfig(configPath string) (Config, error) { func loadOrCreateConfig(configPath string) (Config, error) {
@ -287,6 +270,10 @@ func loadOrCreateConfig(configPath string) (Config, error) {
return cfg, nil return cfg, nil
} }
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
type Sample struct { type Sample struct {
TS time.Time TS time.Time
SilaLPct float32 SilaLPct float32
@ -431,6 +418,10 @@ type AlarmTracker struct {
ImbZone string ImbZone string
} }
// ---------------------------------------------------------------------------
// Package-level singletons
// ---------------------------------------------------------------------------
var ( var (
cfg Config cfg Config
state AppState state AppState
@ -439,20 +430,29 @@ var (
alarmCh chan AlarmEvent alarmCh chan AlarmEvent
alarmTracker AlarmTracker alarmTracker AlarmTracker
uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) 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) lp := float64(leftPercent)
rp := float64(rightPercent) rp := float64(rightPercent)
sumPct := (lp + rp) / 2.0 sumPct := (lp + rp) / 2.0
left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) left := (lp / 100.0) * (maxTonnage / 2.0)
right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) right := (rp / 100.0) * (maxTonnage / 2.0)
total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE total := (sumPct / 100.0) * maxTonnage
return float32(left), float32(right), float32(sumPct), float32(total) return float32(left), float32(right), float32(sumPct), float32(total)
} }
// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------
func snapshotState() APIState { func snapshotState() APIState {
state.RLock() state.RLock()
defer state.RUnlock() 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 { func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
if err != nil { if err != nil {
@ -515,8 +522,7 @@ func ensureColumn(database *sql.DB, tableName, columnName, definition string) er
found := false found := false
for rows.Next() { for rows.Next() {
var cid int var cid int
var name string var name, ctype string
var ctype string
var notNull int var notNull int
var dfltValue sql.NullString var dfltValue sql.NullString
var pk int 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 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) ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
@ -705,6 +715,17 @@ func startDBWriter(database *sql.DB) {
for { for {
select { 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: case s := <-sampleCh:
batch = append(batch, s) batch = append(batch, s)
if len(batch) >= cfg.DB.BatchSize { if len(batch) >= cfg.DB.BatchSize {
@ -716,11 +737,12 @@ func startDBWriter(database *sql.DB) {
} }
} }
func startAlarmWriter(database *sql.DB) { func startAlarmWriter(ctx context.Context, database *sql.DB) {
ticker := time.NewTicker(1000 * time.Millisecond) // 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() defer ticker.Stop()
batch := make([]AlarmEvent, 0, 32) batch := make([]AlarmEvent, 0, cfg.DB.BatchSize)
flush := func() { flush := func() {
if len(batch) == 0 { if len(batch) == 0 {
@ -780,9 +802,20 @@ func startAlarmWriter(database *sql.DB) {
for { for {
select { 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: case a := <-alarmCh:
batch = append(batch, a) batch = append(batch, a)
if len(batch) >= 32 { if len(batch) >= cfg.DB.BatchSize {
flush() flush()
} }
case <-ticker.C: 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 { if cfg.DB.RetentionDays <= 0 {
return return
} }
@ -811,11 +844,20 @@ func startDBCleanup(database *sql.DB) {
cleanup() cleanup()
for range ticker.C { for {
cleanup() select {
case <-ctx.Done():
return
case <-ticker.C:
cleanup()
}
} }
} }
// ---------------------------------------------------------------------------
// Alarm zone helpers
// ---------------------------------------------------------------------------
func zoneFromValue(value float64, warn, crit float64) string { func zoneFromValue(value float64, warn, crit float64) string {
if value >= crit { if value >= crit {
return "critical" return "critical"
@ -974,10 +1016,7 @@ func maybeLogPLCDisconnected(reason string) {
alarmTracker.Lock() alarmTracker.Lock()
defer alarmTracker.Unlock() defer alarmTracker.Unlock()
if !alarmTracker.PLCKnown { if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected {
return
}
if !alarmTracker.PLCConnected {
return 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 { 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 := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot)
handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second
handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second
@ -1007,7 +1060,11 @@ func startPLCPoller() {
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, cfg.PLC.ReconnectDelaySec)
time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second) select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
continue continue
} }
@ -1016,8 +1073,18 @@ func startPLCPoller() {
client := gos7.NewClient(handler) client := gos7.NewClient(handler)
log.Println("PLC connected successfully") 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 { 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 { if err := client.AGReadDB(cfg.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())
@ -1029,7 +1096,7 @@ func startPLCPoller() {
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) leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.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()
@ -1062,11 +1129,20 @@ func startPLCPoller() {
evaluateProcessAlarms(sample) evaluateProcessAlarms(sample)
enqueueSample(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) { func parseWindow(raw string) (time.Duration, string, error) {
s := strings.TrimSpace(strings.ToLower(raw)) s := strings.TrimSpace(strings.ToLower(raw))
if s == "" { if s == "" {
@ -1197,30 +1273,28 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
return stats, nil return stats, nil
} }
func classifyForceDirection(delta float64, oldCount, newCount int) string { // ---------------------------------------------------------------------------
if oldCount < 3 || newCount < 3 { // Trend / stability classification
return "insufficient_data" // ---------------------------------------------------------------------------
}
if math.Abs(delta) < 1.0 {
return "stable"
}
if delta > 0 {
return "rising"
}
return "falling"
}
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 { if oldCount < 3 || newCount < 3 {
return "insufficient_data" return "insufficient_data"
} }
if math.Abs(delta) < 0.5 { if math.Abs(delta) < stableThreshold {
return "stable" return "stable"
} }
if delta > 0 { if delta > 0 {
return "worsening" return posLabel
} }
return "improving" return negLabel
} }
func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) { 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 forceDelta := forceNew.Avg - forceOld.Avg
imbDelta := imbNew.Avg - imbOld.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) fullWindowForce, err := queryNumericStats("sum_pct", startNs, nowNs)
if err != nil { if err != nil {
@ -1360,13 +1435,8 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) {
events := make([]AlarmEventAPI, 0, limit) events := make([]AlarmEventAPI, 0, limit)
for rows.Next() { for rows.Next() {
var ts string var ts, severity, source, state, message string
var severity string var value, limitValue float64
var source string
var state string
var message string
var value float64
var limitValue float64
if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil { if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil {
return nil, err return nil, err
@ -1395,6 +1465,10 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) {
return events, nil return events, nil
} }
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
func apiData(w http.ResponseWriter, r *http.Request) { func apiData(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") 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) { 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{ data := PageData{
Title: cfg.UI.Title, Title: cfg.UI.Title,
Subtitle: cfg.UI.Subtitle, Subtitle: cfg.UI.Subtitle,
@ -1472,7 +1554,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
RightLabel: cfg.UI.RightLabel, RightLabel: cfg.UI.RightLabel,
UnitForce: cfg.UI.UnitForce, UnitForce: cfg.UI.UnitForce,
UnitPct: cfg.UI.UnitPct, UnitPct: cfg.UI.UnitPct,
MaxTonnage: cfg.Press.MAX_TONNAGE, MaxTonnage: cfg.Press.MaxTonnage,
WarningPercent: cfg.Thresholds.WarningPercent, WarningPercent: cfg.Thresholds.WarningPercent,
CriticalPercent: cfg.Thresholds.CriticalPercent, CriticalPercent: cfg.Thresholds.CriticalPercent,
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
@ -1483,13 +1565,17 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
DefaultTrendWindow: "15m", DefaultTrendWindow: "15m",
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") var buf bytes.Buffer
if err := uiTemplate.Execute(w, data); err != nil { if err := uiTemplate.Execute(&buf, data); err != nil {
log.Printf("template execute error: %v", err) log.Fatalf("failed to pre-render UI template: %v", err)
http.Error(w, "render failed", http.StatusInternalServerError)
} }
cachedUI = buf.Bytes()
} }
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func main() { func main() {
wd, err := os.Getwd() wd, err := os.Getwd()
if err != nil { if err != nil {
@ -1514,30 +1600,60 @@ func main() {
defer db.Close() defer db.Close()
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) 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) initCachedUI()
log.Printf("sqlite db: %s", dbPath)
log.Printf("PLC: ip=%s db=%d rack=%d slot=%d poll=%dms", 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) 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) // Graceful shutdown via SIGINT / SIGTERM
go startAlarmWriter(db) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
go startDBCleanup(db) defer stop()
go startPLCPoller()
http.Handle("/static/", http.FileServer(http.FS(staticFiles))) var wg sync.WaitGroup
http.HandleFunc("/", serveUI) wg.Add(4)
http.HandleFunc("/api/data", apiData) go func() { defer wg.Done(); startDBWriter(ctx, db) }()
http.HandleFunc("/api/history", apiHistory) go func() { defer wg.Done(); startAlarmWriter(ctx, db) }()
http.HandleFunc("/api/trend", apiTrend) go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
http.HandleFunc("/api/alarms", apiAlarms) go func() { defer wg.Done(); startPLCPoller(ctx) }()
log.Println("S7-1200 Force Monitor started") mux := http.NewServeMux()
log.Println("VERSION 0.7.0") mux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) mux.HandleFunc("/", serveUI)
log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) 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 = `<!DOCTYPE html> const uiHTML = `<!DOCTYPE html>