v9.0 - added auto reload at yaml change
This commit is contained in:
parent
15e390b693
commit
8d6076d046
358
main.go
358
main.go
|
|
@ -16,12 +16,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/robinson/gos7"
|
"github.com/robinson/gos7"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -30,7 +32,7 @@ import (
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
const version = "0.8.2"
|
const version = "0.9.0"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config structs
|
// Config structs
|
||||||
|
|
@ -289,6 +291,24 @@ func normalizeConfig(cfg *Config) {
|
||||||
setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr)
|
setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadConfigStrict(configPath string) (Config, error) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
dec.KnownFields(true)
|
||||||
|
if err := dec.Decode(&cfg); err != nil {
|
||||||
|
return cfg, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeConfig(&cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadOrCreateConfig(configPath string) (Config, error) {
|
func loadOrCreateConfig(configPath string) (Config, error) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
|
@ -308,16 +328,32 @@ func loadOrCreateConfig(configPath string) (Config, error) {
|
||||||
return cfg, fmt.Errorf("failed to stat config file: %w", err)
|
return cfg, fmt.Errorf("failed to stat config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
return loadConfigStrict(configPath)
|
||||||
if err != nil {
|
}
|
||||||
return cfg, fmt.Errorf("failed to read config file: %w", err)
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return cfg, fmt.Errorf("failed to parse config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeConfig(&cfg)
|
func validateConfig(cfg Config) error {
|
||||||
return cfg, nil
|
if cfg.Thresholds.WarningPercent <= 0 {
|
||||||
|
return fmt.Errorf("thresholds.warning_percent must be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.CriticalPercent < cfg.Thresholds.WarningPercent {
|
||||||
|
return fmt.Errorf("thresholds.critical_percent must be >= thresholds.warning_percent")
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.GaugeMaxPercent < cfg.Thresholds.CriticalPercent {
|
||||||
|
return fmt.Errorf("thresholds.gauge_max_percent must be >= thresholds.critical_percent")
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.ImbalanceWarningPercent <= 0 {
|
||||||
|
return fmt.Errorf("thresholds.imbalance_warning_percent must be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
|
||||||
|
return fmt.Errorf("thresholds.imbalance_critical_percent must be >= thresholds.imbalance_warning_percent")
|
||||||
|
}
|
||||||
|
if cfg.Trend.Minutes <= 0 {
|
||||||
|
return fmt.Errorf("trend.minutes must be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Press.MaxTonnage <= 0 {
|
||||||
|
return fmt.Errorf("press.MAX_TONNAGE must be > 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -484,6 +520,7 @@ type AlarmTracker struct {
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg Config
|
cfg Config
|
||||||
|
cfgMu sync.RWMutex
|
||||||
state AppState
|
state AppState
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
sampleCh chan Sample
|
sampleCh chan Sample
|
||||||
|
|
@ -509,6 +546,209 @@ func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (lef
|
||||||
return float32(left), float32(right), float32(sumPct), float32(total)
|
return float32(left), float32(right), float32(sumPct), float32(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getConfigSnapshot() Config {
|
||||||
|
cfgMu.RLock()
|
||||||
|
defer cfgMu.RUnlock()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCachedUI(config Config) ([]byte, error) {
|
||||||
|
data := PageData{
|
||||||
|
Title: config.UI.Title,
|
||||||
|
Subtitle: config.UI.Subtitle,
|
||||||
|
LeftLabel: config.UI.LeftLabel,
|
||||||
|
RightLabel: config.UI.RightLabel,
|
||||||
|
UnitForce: config.UI.UnitForce,
|
||||||
|
UnitPct: config.UI.UnitPct,
|
||||||
|
MaxTonnage: config.Press.MaxTonnage,
|
||||||
|
WarningPercent: config.Thresholds.WarningPercent,
|
||||||
|
CriticalPercent: config.Thresholds.CriticalPercent,
|
||||||
|
GaugeMaxPercent: config.Thresholds.GaugeMaxPercent,
|
||||||
|
ImbalanceWarningPercent: config.Thresholds.ImbalanceWarningPercent,
|
||||||
|
ImbalanceCriticalPercent: config.Thresholds.ImbalanceCriticalPercent,
|
||||||
|
PollMs: config.PLC.PollMs,
|
||||||
|
DefaultWindow: fmt.Sprintf("%dm", config.Trend.Minutes),
|
||||||
|
DefaultTrendWindow: "15m",
|
||||||
|
|
||||||
|
ShowHeaderControls: boolValue(config.Modules.ShowHeaderControls, true),
|
||||||
|
ShowVerdict: boolValue(config.Modules.ShowVerdict, true),
|
||||||
|
ShowSummaryBar: boolValue(config.Modules.ShowSummaryBar, true),
|
||||||
|
ShowOverview: boolValue(config.Modules.ShowOverview, true),
|
||||||
|
ShowIntelligence: boolValue(config.Modules.ShowIntelligence, true),
|
||||||
|
ShowAlarmTimeline: boolValue(config.Modules.ShowAlarmTimeline, true),
|
||||||
|
ShowGauges: boolValue(config.Modules.ShowGauges, true),
|
||||||
|
ShowGaugeDigital: boolValue(config.Modules.ShowGaugeDigital, false),
|
||||||
|
ShowTrendChart: boolValue(config.Modules.ShowTrendChart, true),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := uiTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initCachedUI() {
|
||||||
|
config := getConfigSnapshot()
|
||||||
|
payload, err := buildCachedUI(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to pre-render UI template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMu.Lock()
|
||||||
|
cachedUI = payload
|
||||||
|
cfgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hotReloadSectionsLocked(dst *Config, src Config) {
|
||||||
|
dst.Thresholds = src.Thresholds
|
||||||
|
dst.Trend = src.Trend
|
||||||
|
dst.Press = src.Press
|
||||||
|
dst.UI = src.UI
|
||||||
|
dst.Modules = src.Modules
|
||||||
|
}
|
||||||
|
|
||||||
|
func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartSections []string) {
|
||||||
|
if !reflect.DeepEqual(oldCfg.Thresholds, newCfg.Thresholds) {
|
||||||
|
hotSections = append(hotSections, "thresholds")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.Trend, newCfg.Trend) {
|
||||||
|
hotSections = append(hotSections, "trend")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.Press, newCfg.Press) {
|
||||||
|
hotSections = append(hotSections, "press")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.UI, newCfg.UI) {
|
||||||
|
hotSections = append(hotSections, "ui")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.Modules, newCfg.Modules) {
|
||||||
|
hotSections = append(hotSections, "modules")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(oldCfg.Server, newCfg.Server) {
|
||||||
|
restartSections = append(restartSections, "server")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.PLC, newCfg.PLC) {
|
||||||
|
restartSections = append(restartSections, "plc")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(oldCfg.DB, newCfg.DB) {
|
||||||
|
restartSections = append(restartSections, "db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hotSections, restartSections
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadConfigSafely(configPath string) {
|
||||||
|
newCfg, err := loadConfigStrict(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("config reload rejected: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateConfig(newCfg); err != nil {
|
||||||
|
log.Printf("config reload rejected: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oldCfg := getConfigSnapshot()
|
||||||
|
hotSections, restartSections := configSectionChanges(oldCfg, newCfg)
|
||||||
|
|
||||||
|
updatedCfg := oldCfg
|
||||||
|
hotReloadSectionsLocked(&updatedCfg, newCfg)
|
||||||
|
|
||||||
|
payload, err := buildCachedUI(updatedCfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("config reload rejected: failed to rebuild UI: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMu.Lock()
|
||||||
|
cfg = updatedCfg
|
||||||
|
cachedUI = payload
|
||||||
|
cfgMu.Unlock()
|
||||||
|
|
||||||
|
if len(hotSections) == 0 && len(restartSections) == 0 {
|
||||||
|
log.Printf("config reload checked: no effective changes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(hotSections) > 0 {
|
||||||
|
log.Printf("config hot-reloaded safely: %s", strings.Join(hotSections, ", "))
|
||||||
|
}
|
||||||
|
if len(restartSections) > 0 {
|
||||||
|
log.Printf("config changes detected in %s; restart required before they take effect", strings.Join(restartSections, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConfigWatcher(ctx context.Context, configPath string) error {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
target := filepath.Clean(configPath)
|
||||||
|
if err := watcher.Add(dir); err != nil {
|
||||||
|
_ = watcher.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
debounceTimer *time.Timer
|
||||||
|
debounceC <-chan time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
resetDebounce := func() {
|
||||||
|
if debounceTimer == nil {
|
||||||
|
debounceTimer = time.NewTimer(350 * time.Millisecond)
|
||||||
|
} else {
|
||||||
|
if !debounceTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-debounceTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debounceTimer.Reset(350 * time.Millisecond)
|
||||||
|
}
|
||||||
|
debounceC = debounceTimer.C
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if debounceTimer != nil {
|
||||||
|
debounceTimer.Stop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case event, ok := <-watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if filepath.Clean(event.Name) != target {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if event.Has(fsnotify.Chmod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
|
||||||
|
resetDebounce()
|
||||||
|
}
|
||||||
|
case <-debounceC:
|
||||||
|
debounceC = nil
|
||||||
|
reloadConfigSafely(configPath)
|
||||||
|
case err, ok := <-watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("config watcher error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State helpers
|
// State helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -938,20 +1178,21 @@ func sourceName(source string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceLimit(source, zone string) float64 {
|
func sourceLimit(source, zone string) float64 {
|
||||||
|
config := getConfigSnapshot()
|
||||||
switch source {
|
switch source {
|
||||||
case "imbalance":
|
case "imbalance":
|
||||||
if zone == "critical" {
|
if zone == "critical" {
|
||||||
return cfg.Thresholds.ImbalanceCriticalPercent
|
return config.Thresholds.ImbalanceCriticalPercent
|
||||||
}
|
}
|
||||||
if zone == "warning" {
|
if zone == "warning" {
|
||||||
return cfg.Thresholds.ImbalanceWarningPercent
|
return config.Thresholds.ImbalanceWarningPercent
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if zone == "critical" {
|
if zone == "critical" {
|
||||||
return cfg.Thresholds.CriticalPercent
|
return config.Thresholds.CriticalPercent
|
||||||
}
|
}
|
||||||
if zone == "warning" {
|
if zone == "warning" {
|
||||||
return cfg.Thresholds.WarningPercent
|
return config.Thresholds.WarningPercent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1015,9 +1256,10 @@ func maybeLogZoneChange(source, prev, curr string, value float64) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluateProcessAlarms(s Sample) {
|
func evaluateProcessAlarms(s Sample) {
|
||||||
leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
|
config := getConfigSnapshot()
|
||||||
rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
|
leftZone := zoneFromValue(float64(s.SilaLPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
|
||||||
imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent)
|
rightZone := zoneFromValue(float64(s.SilaRPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
|
||||||
|
imbZone := zoneFromValue(float64(s.ImbalancePercent), config.Thresholds.ImbalanceWarningPercent, config.Thresholds.ImbalanceCriticalPercent)
|
||||||
|
|
||||||
alarmTracker.Lock()
|
alarmTracker.Lock()
|
||||||
defer alarmTracker.Unlock()
|
defer alarmTracker.Unlock()
|
||||||
|
|
@ -1096,8 +1338,9 @@ func maybeLogPLCDisconnected(reason string) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func startPLCPoller(ctx context.Context) {
|
func startPLCPoller(ctx context.Context) {
|
||||||
pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond
|
bootCfg := getConfigSnapshot()
|
||||||
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond
|
||||||
|
reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|
@ -1106,13 +1349,13 @@ func startPLCPoller(ctx context.Context) {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot)
|
handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
|
||||||
handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second
|
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
||||||
handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second
|
handler.IdleTimeout = time.Duration(bootCfg.PLC.IdleTimeoutSec) * time.Second
|
||||||
|
|
||||||
if err := handler.Connect(); err != nil {
|
if err := handler.Connect(); err != nil {
|
||||||
markDisconnected(err.Error())
|
markDisconnected(err.Error())
|
||||||
log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec)
|
log.Printf("PLC connect failed: %v - retrying in %ds...", err, bootCfg.PLC.ReconnectDelaySec)
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|
@ -1136,7 +1379,7 @@ func startPLCPoller(ctx context.Context) {
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil {
|
if err := client.AGReadDB(bootCfg.PLC.DBNum, 0, 8, buf); err != nil {
|
||||||
log.Printf("PLC read error: %v - reconnecting...", err)
|
log.Printf("PLC read error: %v - reconnecting...", err)
|
||||||
markDisconnected(err.Error())
|
markDisconnected(err.Error())
|
||||||
_ = handler.Close()
|
_ = handler.Close()
|
||||||
|
|
@ -1147,7 +1390,7 @@ func startPLCPoller(ctx context.Context) {
|
||||||
silaL := helper.GetRealAt(buf, 0)
|
silaL := helper.GetRealAt(buf, 0)
|
||||||
silaR := helper.GetRealAt(buf, 4)
|
silaR := helper.GetRealAt(buf, 4)
|
||||||
|
|
||||||
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.Press.MaxTonnage)
|
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, getConfigSnapshot().Press.MaxTonnage)
|
||||||
imbalance := float32(math.Abs(float64(silaL - silaR)))
|
imbalance := float32(math.Abs(float64(silaL - silaR)))
|
||||||
bias := silaL - silaR
|
bias := silaL - silaR
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
@ -1197,7 +1440,7 @@ func startPLCPoller(ctx context.Context) {
|
||||||
func parseWindow(raw string) (time.Duration, string, error) {
|
func parseWindow(raw string) (time.Duration, string, error) {
|
||||||
s := strings.TrimSpace(strings.ToLower(raw))
|
s := strings.TrimSpace(strings.ToLower(raw))
|
||||||
if s == "" {
|
if s == "" {
|
||||||
s = fmt.Sprintf("%dm", cfg.Trend.Minutes)
|
s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(s, "d") {
|
if strings.HasSuffix(s, "d") {
|
||||||
|
|
@ -1346,8 +1589,10 @@ func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sa
|
||||||
return "insufficient_data", "Too few samples in selected trend window"
|
return "insufficient_data", "Too few samples in selected trend window"
|
||||||
}
|
}
|
||||||
|
|
||||||
if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
|
config := getConfigSnapshot()
|
||||||
if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent {
|
|
||||||
|
if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= config.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
|
||||||
|
if avgImb5m >= config.Thresholds.ImbalanceCriticalPercent {
|
||||||
return "unstable", "High average imbalance in last 5 minutes"
|
return "unstable", "High average imbalance in last 5 minutes"
|
||||||
}
|
}
|
||||||
if math.Abs(forceDelta) >= 8.0 {
|
if math.Abs(forceDelta) >= 8.0 {
|
||||||
|
|
@ -1359,8 +1604,8 @@ func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sa
|
||||||
return "unstable", "Imbalance variation is too high"
|
return "unstable", "Imbalance variation is too high"
|
||||||
}
|
}
|
||||||
|
|
||||||
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
|
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
|
||||||
if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent {
|
if avgImb5m >= config.Thresholds.ImbalanceWarningPercent {
|
||||||
return "caution", "Imbalance is trending above warning region"
|
return "caution", "Imbalance is trending above warning region"
|
||||||
}
|
}
|
||||||
if math.Abs(forceDelta) >= 3.0 {
|
if math.Abs(forceDelta) >= 3.0 {
|
||||||
|
|
@ -1583,44 +1828,12 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfgMu.RLock()
|
||||||
|
payload := cachedUI
|
||||||
|
cfgMu.RUnlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write(cachedUI)
|
_, _ = w.Write(payload)
|
||||||
}
|
|
||||||
|
|
||||||
func initCachedUI() {
|
|
||||||
data := PageData{
|
|
||||||
Title: cfg.UI.Title,
|
|
||||||
Subtitle: cfg.UI.Subtitle,
|
|
||||||
LeftLabel: cfg.UI.LeftLabel,
|
|
||||||
RightLabel: cfg.UI.RightLabel,
|
|
||||||
UnitForce: cfg.UI.UnitForce,
|
|
||||||
UnitPct: cfg.UI.UnitPct,
|
|
||||||
MaxTonnage: cfg.Press.MaxTonnage,
|
|
||||||
WarningPercent: cfg.Thresholds.WarningPercent,
|
|
||||||
CriticalPercent: cfg.Thresholds.CriticalPercent,
|
|
||||||
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
|
|
||||||
ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent,
|
|
||||||
ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent,
|
|
||||||
PollMs: cfg.PLC.PollMs,
|
|
||||||
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
|
||||||
DefaultTrendWindow: "15m",
|
|
||||||
|
|
||||||
ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true),
|
|
||||||
ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true),
|
|
||||||
ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true),
|
|
||||||
ShowOverview: boolValue(cfg.Modules.ShowOverview, true),
|
|
||||||
ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true),
|
|
||||||
ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true),
|
|
||||||
ShowGauges: boolValue(cfg.Modules.ShowGauges, true),
|
|
||||||
ShowGaugeDigital: boolValue(cfg.Modules.ShowGaugeDigital, false),
|
|
||||||
ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true),
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := uiTemplate.Execute(&buf, data); err != nil {
|
|
||||||
log.Fatalf("failed to pre-render UI template: %v", err)
|
|
||||||
}
|
|
||||||
cachedUI = buf.Bytes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1638,6 +1851,9 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to load config: %v", err)
|
log.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
log.Fatalf("invalid config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
dbPath := cfg.DB.Path
|
dbPath := cfg.DB.Path
|
||||||
if !filepath.IsAbs(dbPath) {
|
if !filepath.IsAbs(dbPath) {
|
||||||
|
|
@ -1665,6 +1881,12 @@ func main() {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
if err := startConfigWatcher(ctx, configPath); err != nil {
|
||||||
|
log.Printf("config watch disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Config watcher enabled for %s", configPath)
|
||||||
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(4)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); startDBWriter(ctx, db) }()
|
go func() { defer wg.Done(); startDBWriter(ctx, db) }()
|
||||||
|
|
@ -2346,7 +2568,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .ShowGauges}}
|
{{if .ShowGauges}}
|
||||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-8 mb-8">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
|
||||||
<div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
|
<div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
|
||||||
{{if .ShowGaugeDigital}}
|
{{if .ShowGaugeDigital}}
|
||||||
<div class="gauge-header-row">
|
<div class="gauge-header-row">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue