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:
parent
3c9509169d
commit
8cd6e066e8
442
main.go
442
main.go
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue