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/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/robinson/gos7"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -30,7 +32,7 @@ import (
|
|||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const version = "0.8.2"
|
||||
const version = "0.9.0"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config structs
|
||||
|
|
@ -289,6 +291,24 @@ func normalizeConfig(cfg *Config) {
|
|||
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) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
|
|
@ -308,16 +328,32 @@ func loadOrCreateConfig(configPath string) (Config, error) {
|
|||
return cfg, fmt.Errorf("failed to stat config file: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(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)
|
||||
}
|
||||
return loadConfigStrict(configPath)
|
||||
}
|
||||
|
||||
normalizeConfig(&cfg)
|
||||
return cfg, nil
|
||||
func validateConfig(cfg Config) error {
|
||||
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 (
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
state AppState
|
||||
db *sql.DB
|
||||
sampleCh chan Sample
|
||||
|
|
@ -509,6 +546,209 @@ func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (lef
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -938,20 +1178,21 @@ func sourceName(source string) string {
|
|||
}
|
||||
|
||||
func sourceLimit(source, zone string) float64 {
|
||||
config := getConfigSnapshot()
|
||||
switch source {
|
||||
case "imbalance":
|
||||
if zone == "critical" {
|
||||
return cfg.Thresholds.ImbalanceCriticalPercent
|
||||
return config.Thresholds.ImbalanceCriticalPercent
|
||||
}
|
||||
if zone == "warning" {
|
||||
return cfg.Thresholds.ImbalanceWarningPercent
|
||||
return config.Thresholds.ImbalanceWarningPercent
|
||||
}
|
||||
default:
|
||||
if zone == "critical" {
|
||||
return cfg.Thresholds.CriticalPercent
|
||||
return config.Thresholds.CriticalPercent
|
||||
}
|
||||
if zone == "warning" {
|
||||
return cfg.Thresholds.WarningPercent
|
||||
return config.Thresholds.WarningPercent
|
||||
}
|
||||
}
|
||||
return 0
|
||||
|
|
@ -1015,9 +1256,10 @@ func maybeLogZoneChange(source, prev, curr string, value float64) {
|
|||
}
|
||||
|
||||
func evaluateProcessAlarms(s Sample) {
|
||||
leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
|
||||
rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
|
||||
imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent)
|
||||
config := getConfigSnapshot()
|
||||
leftZone := zoneFromValue(float64(s.SilaLPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
|
||||
rightZone := zoneFromValue(float64(s.SilaRPct), config.Thresholds.WarningPercent, config.Thresholds.CriticalPercent)
|
||||
imbZone := zoneFromValue(float64(s.ImbalancePercent), config.Thresholds.ImbalanceWarningPercent, config.Thresholds.ImbalanceCriticalPercent)
|
||||
|
||||
alarmTracker.Lock()
|
||||
defer alarmTracker.Unlock()
|
||||
|
|
@ -1096,8 +1338,9 @@ func maybeLogPLCDisconnected(reason string) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
func startPLCPoller(ctx context.Context) {
|
||||
pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond
|
||||
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
||||
bootCfg := getConfigSnapshot()
|
||||
pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond
|
||||
reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
@ -1106,13 +1349,13 @@ func startPLCPoller(ctx context.Context) {
|
|||
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
|
||||
handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
|
||||
handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
|
||||
handler.IdleTimeout = time.Duration(bootCfg.PLC.IdleTimeoutSec) * time.Second
|
||||
|
||||
if err := handler.Connect(); err != nil {
|
||||
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 {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
|
@ -1136,7 +1379,7 @@ func startPLCPoller(ctx context.Context) {
|
|||
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)
|
||||
markDisconnected(err.Error())
|
||||
_ = handler.Close()
|
||||
|
|
@ -1147,7 +1390,7 @@ func startPLCPoller(ctx context.Context) {
|
|||
silaL := helper.GetRealAt(buf, 0)
|
||||
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)))
|
||||
bias := silaL - silaR
|
||||
now := time.Now()
|
||||
|
|
@ -1197,7 +1440,7 @@ func startPLCPoller(ctx context.Context) {
|
|||
func parseWindow(raw string) (time.Duration, string, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
s = fmt.Sprintf("%dm", cfg.Trend.Minutes)
|
||||
s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
|
||||
if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent {
|
||||
config := getConfigSnapshot()
|
||||
|
||||
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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
|
||||
if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent {
|
||||
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
|
||||
if avgImb5m >= config.Thresholds.ImbalanceWarningPercent {
|
||||
return "caution", "Imbalance is trending above warning region"
|
||||
}
|
||||
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) {
|
||||
cfgMu.RLock()
|
||||
payload := cachedUI
|
||||
cfgMu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(cachedUI)
|
||||
}
|
||||
|
||||
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()
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1638,6 +1851,9 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
log.Fatalf("invalid config: %v", err)
|
||||
}
|
||||
|
||||
dbPath := cfg.DB.Path
|
||||
if !filepath.IsAbs(dbPath) {
|
||||
|
|
@ -1665,6 +1881,12 @@ func main() {
|
|||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
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
|
||||
wg.Add(4)
|
||||
go func() { defer wg.Done(); startDBWriter(ctx, db) }()
|
||||
|
|
@ -2346,7 +2568,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
{{end}}
|
||||
|
||||
{{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">
|
||||
{{if .ShowGaugeDigital}}
|
||||
<div class="gauge-header-row">
|
||||
|
|
|
|||
Loading…
Reference in a new issue