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
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 = `<!DOCTYPE html>