2026-04-16 11:51:18 +00:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-17 15:44:57 +00:00
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
2026-04-16 15:46:07 +00:00
|
|
|
|
"database/sql"
|
2026-04-17 07:57:32 +00:00
|
|
|
|
"embed"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-04-16 15:03:02 +00:00
|
|
|
|
"html/template"
|
2026-04-17 17:32:33 +00:00
|
|
|
|
"io/fs"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"log"
|
2026-04-16 17:02:59 +00:00
|
|
|
|
"math"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"os"
|
2026-04-17 15:44:57 +00:00
|
|
|
|
"os/signal"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"path/filepath"
|
2026-04-19 10:33:14 +00:00
|
|
|
|
"reflect"
|
2026-04-16 15:46:07 +00:00
|
|
|
|
"strconv"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
2026-04-17 15:44:57 +00:00
|
|
|
|
"syscall"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
"github.com/fsnotify/fsnotify"
|
2026-04-16 15:46:07 +00:00
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
2026-04-16 11:51:18 +00:00
|
|
|
|
"github.com/robinson/gos7"
|
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-17 07:57:32 +00:00
|
|
|
|
//go:embed static
|
|
|
|
|
|
var staticFiles embed.FS
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
const version = "0.9.0"
|
2026-04-17 15:44:57 +00:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Config structs
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
type Config struct {
|
|
|
|
|
|
Server ServerConfig `yaml:"server"`
|
|
|
|
|
|
PLC PLCConfig `yaml:"plc"`
|
|
|
|
|
|
Thresholds ThresholdsConfig `yaml:"thresholds"`
|
|
|
|
|
|
Trend TrendConfig `yaml:"trend"`
|
|
|
|
|
|
Press PressConfig `yaml:"press"`
|
|
|
|
|
|
UI UIConfig `yaml:"ui"`
|
2026-04-17 17:32:33 +00:00
|
|
|
|
Modules ModulesConfig `yaml:"modules"`
|
2026-04-16 15:46:07 +00:00
|
|
|
|
DB DBConfig `yaml:"db"`
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ServerConfig struct {
|
|
|
|
|
|
ListenAddr string `yaml:"listen_addr"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PLCConfig struct {
|
|
|
|
|
|
IP string `yaml:"ip"`
|
|
|
|
|
|
DBNum int `yaml:"db_num"`
|
|
|
|
|
|
Rack int `yaml:"rack"`
|
|
|
|
|
|
Slot int `yaml:"slot"`
|
|
|
|
|
|
PollMs int `yaml:"poll_ms"`
|
|
|
|
|
|
ConnectTimeoutSec int `yaml:"connect_timeout_sec"`
|
|
|
|
|
|
IdleTimeoutSec int `yaml:"idle_timeout_sec"`
|
|
|
|
|
|
ReconnectDelaySec int `yaml:"reconnect_delay_sec"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ThresholdsConfig struct {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
WarningPercent float64 `yaml:"warning_percent"`
|
|
|
|
|
|
CriticalPercent float64 `yaml:"critical_percent"`
|
|
|
|
|
|
GaugeMaxPercent float64 `yaml:"gauge_max_percent"`
|
|
|
|
|
|
ImbalanceWarningPercent float64 `yaml:"imbalance_warning_percent"`
|
|
|
|
|
|
ImbalanceCriticalPercent float64 `yaml:"imbalance_critical_percent"`
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
LegacyWarningKn float64 `yaml:"warning_kn,omitempty"`
|
|
|
|
|
|
LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
|
|
|
|
|
|
LegacyMaxKn float64 `yaml:"max_kn,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TrendConfig struct {
|
|
|
|
|
|
Minutes int `yaml:"minutes"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PressConfig struct {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
|
2026-04-16 11:51:18 +00:00
|
|
|
|
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type UIConfig struct {
|
|
|
|
|
|
Title string `yaml:"title"`
|
|
|
|
|
|
Subtitle string `yaml:"subtitle"`
|
|
|
|
|
|
LeftLabel string `yaml:"left_label"`
|
|
|
|
|
|
RightLabel string `yaml:"right_label"`
|
|
|
|
|
|
UnitForce string `yaml:"unit_force"`
|
|
|
|
|
|
UnitPct string `yaml:"unit_percent"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
type ModulesConfig struct {
|
|
|
|
|
|
ShowHeaderControls *bool `yaml:"show_header_controls,omitempty"`
|
|
|
|
|
|
ShowVerdict *bool `yaml:"show_verdict,omitempty"`
|
|
|
|
|
|
ShowSummaryBar *bool `yaml:"show_summary_bar,omitempty"`
|
|
|
|
|
|
ShowOverview *bool `yaml:"show_overview,omitempty"`
|
|
|
|
|
|
ShowIntelligence *bool `yaml:"show_intelligence,omitempty"`
|
|
|
|
|
|
ShowAlarmTimeline *bool `yaml:"show_alarm_timeline,omitempty"`
|
|
|
|
|
|
ShowGauges *bool `yaml:"show_gauges,omitempty"`
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ShowGaugeDigital *bool `yaml:"show_gauge_digital,omitempty"`
|
2026-04-17 17:32:33 +00:00
|
|
|
|
ShowTrendChart *bool `yaml:"show_trend_chart,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
type DBConfig struct {
|
|
|
|
|
|
Path string `yaml:"path"`
|
|
|
|
|
|
BusyTimeoutMs int `yaml:"busy_timeout_ms"`
|
|
|
|
|
|
BatchSize int `yaml:"batch_size"`
|
|
|
|
|
|
FlushIntervalMs int `yaml:"flush_interval_ms"`
|
|
|
|
|
|
RetentionDays int `yaml:"retention_days"`
|
|
|
|
|
|
MaxChartPoints int `yaml:"max_chart_points"`
|
|
|
|
|
|
WriterQueueSize int `yaml:"writer_queue_size"`
|
2026-04-17 15:44:57 +00:00
|
|
|
|
AlarmQueueSize int `yaml:"alarm_queue_size"`
|
2026-04-16 15:46:07 +00:00
|
|
|
|
CheckpointPages int `yaml:"checkpoint_pages"`
|
|
|
|
|
|
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
func boolPtr(v bool) *bool {
|
|
|
|
|
|
return &v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func boolValue(v *bool, def bool) bool {
|
|
|
|
|
|
if v == nil {
|
|
|
|
|
|
return def
|
|
|
|
|
|
}
|
|
|
|
|
|
return *v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
func defaultConfig() Config {
|
|
|
|
|
|
return Config{
|
2026-04-16 17:27:50 +00:00
|
|
|
|
Server: ServerConfig{
|
|
|
|
|
|
ListenAddr: ":8080",
|
|
|
|
|
|
},
|
2026-04-16 11:51:18 +00:00
|
|
|
|
PLC: PLCConfig{
|
|
|
|
|
|
IP: "192.168.0.1",
|
|
|
|
|
|
DBNum: 1001,
|
|
|
|
|
|
Rack: 0,
|
|
|
|
|
|
Slot: 1,
|
|
|
|
|
|
PollMs: 500,
|
|
|
|
|
|
ConnectTimeoutSec: 5,
|
|
|
|
|
|
IdleTimeoutSec: 5,
|
|
|
|
|
|
ReconnectDelaySec: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
Thresholds: ThresholdsConfig{
|
2026-04-16 17:27:50 +00:00
|
|
|
|
WarningPercent: 80,
|
|
|
|
|
|
CriticalPercent: 95,
|
|
|
|
|
|
GaugeMaxPercent: 130,
|
|
|
|
|
|
ImbalanceWarningPercent: 10,
|
|
|
|
|
|
ImbalanceCriticalPercent: 20,
|
|
|
|
|
|
},
|
|
|
|
|
|
Trend: TrendConfig{
|
|
|
|
|
|
Minutes: 5,
|
|
|
|
|
|
},
|
|
|
|
|
|
Press: PressConfig{
|
2026-04-17 15:44:57 +00:00
|
|
|
|
MaxTonnage: 64,
|
2026-04-16 11:51:18 +00:00
|
|
|
|
},
|
|
|
|
|
|
UI: UIConfig{
|
2026-04-16 17:27:50 +00:00
|
|
|
|
Title: "Force Monitor",
|
2026-04-16 17:02:59 +00:00
|
|
|
|
Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
|
2026-04-16 11:51:18 +00:00
|
|
|
|
LeftLabel: "LEVI STEBER",
|
|
|
|
|
|
RightLabel: "DESNI STEBER",
|
|
|
|
|
|
UnitForce: "kN",
|
|
|
|
|
|
UnitPct: "%",
|
|
|
|
|
|
},
|
2026-04-17 17:32:33 +00:00
|
|
|
|
Modules: ModulesConfig{
|
|
|
|
|
|
ShowHeaderControls: boolPtr(true),
|
|
|
|
|
|
ShowVerdict: boolPtr(true),
|
|
|
|
|
|
ShowSummaryBar: boolPtr(true),
|
|
|
|
|
|
ShowOverview: boolPtr(true),
|
|
|
|
|
|
ShowIntelligence: boolPtr(true),
|
|
|
|
|
|
ShowAlarmTimeline: boolPtr(true),
|
|
|
|
|
|
ShowGauges: boolPtr(true),
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ShowGaugeDigital: boolPtr(false),
|
2026-04-17 17:32:33 +00:00
|
|
|
|
ShowTrendChart: boolPtr(true),
|
|
|
|
|
|
},
|
2026-04-16 15:46:07 +00:00
|
|
|
|
DB: DBConfig{
|
|
|
|
|
|
Path: "force_monitor.db",
|
|
|
|
|
|
BusyTimeoutMs: 5000,
|
|
|
|
|
|
BatchSize: 32,
|
|
|
|
|
|
FlushIntervalMs: 1000,
|
|
|
|
|
|
RetentionDays: 30,
|
|
|
|
|
|
MaxChartPoints: 2000,
|
|
|
|
|
|
WriterQueueSize: 4096,
|
2026-04-17 15:44:57 +00:00
|
|
|
|
AlarmQueueSize: 512,
|
2026-04-16 15:46:07 +00:00
|
|
|
|
CheckpointPages: 1000,
|
|
|
|
|
|
CleanupIntervalHr: 6,
|
|
|
|
|
|
},
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-17 17:32:33 +00:00
|
|
|
|
// Config normalisation helpers
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
func setIfZeroF(dst *float64, def float64) {
|
|
|
|
|
|
if *dst <= 0 {
|
|
|
|
|
|
*dst = def
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
func setIfZeroI(dst *int, def int) {
|
|
|
|
|
|
if *dst <= 0 {
|
|
|
|
|
|
*dst = def
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func setIfEmpty(dst *string, def string) {
|
|
|
|
|
|
if strings.TrimSpace(*dst) == "" {
|
|
|
|
|
|
*dst = def
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
func setIfNilBool(dst **bool, def bool) {
|
|
|
|
|
|
if *dst == nil {
|
|
|
|
|
|
v := def
|
|
|
|
|
|
*dst = &v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
func normalizeConfig(cfg *Config) {
|
|
|
|
|
|
def := defaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
setIfEmpty(&cfg.Server.ListenAddr, def.Server.ListenAddr)
|
|
|
|
|
|
|
|
|
|
|
|
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
|
|
|
|
|
|
setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
|
2026-04-17 17:32:33 +00:00
|
|
|
|
setIfZeroI(&cfg.PLC.Rack, def.PLC.Rack)
|
|
|
|
|
|
setIfZeroI(&cfg.PLC.Slot, def.PLC.Slot)
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
|
|
|
|
|
|
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Thresholds.CriticalPercent <= 0 && cfg.Thresholds.LegacyCriticalKn > 0 {
|
|
|
|
|
|
cfg.Thresholds.CriticalPercent = cfg.Thresholds.LegacyCriticalKn
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 {
|
|
|
|
|
|
cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
|
|
|
|
|
|
}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
|
|
|
|
|
|
cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
|
|
|
|
|
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
setIfNilBool(&cfg.Modules.ShowHeaderControls, boolValue(def.Modules.ShowHeaderControls, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowVerdict, boolValue(def.Modules.ShowVerdict, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowSummaryBar, boolValue(def.Modules.ShowSummaryBar, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowOverview, boolValue(def.Modules.ShowOverview, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowIntelligence, boolValue(def.Modules.ShowIntelligence, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowAlarmTimeline, boolValue(def.Modules.ShowAlarmTimeline, true))
|
|
|
|
|
|
setIfNilBool(&cfg.Modules.ShowGauges, boolValue(def.Modules.ShowGauges, true))
|
2026-04-19 07:22:16 +00:00
|
|
|
|
setIfNilBool(&cfg.Modules.ShowGaugeDigital, boolValue(def.Modules.ShowGaugeDigital, false))
|
2026-04-17 17:32:33 +00:00
|
|
|
|
setIfNilBool(&cfg.Modules.ShowTrendChart, boolValue(def.Modules.ShowTrendChart, true))
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
func loadOrCreateConfig(configPath string) (Config, error) {
|
|
|
|
|
|
cfg := defaultConfig()
|
|
|
|
|
|
|
|
|
|
|
|
_, err := os.Stat(configPath)
|
|
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
|
data, marshalErr := yaml.Marshal(&cfg)
|
|
|
|
|
|
if marshalErr != nil {
|
|
|
|
|
|
return cfg, fmt.Errorf("failed to marshal default config: %w", marshalErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
if writeErr := os.WriteFile(configPath, data, 0644); writeErr != nil {
|
|
|
|
|
|
return cfg, fmt.Errorf("failed to create config file: %w", writeErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("config file not found, created default config: %s", configPath)
|
|
|
|
|
|
return cfg, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return cfg, fmt.Errorf("failed to stat config file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
return loadConfigStrict(configPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func validateConfig(cfg Config) error {
|
|
|
|
|
|
if cfg.Thresholds.WarningPercent <= 0 {
|
|
|
|
|
|
return fmt.Errorf("thresholds.warning_percent must be > 0")
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-19 10:33:14 +00:00
|
|
|
|
if cfg.Thresholds.CriticalPercent < cfg.Thresholds.WarningPercent {
|
|
|
|
|
|
return fmt.Errorf("thresholds.critical_percent must be >= thresholds.warning_percent")
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-19 10:33:14 +00:00
|
|
|
|
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
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Domain types
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
type Sample struct {
|
2026-04-16 17:02:59 +00:00
|
|
|
|
TS time.Time
|
|
|
|
|
|
SilaLPct float32
|
|
|
|
|
|
SilaRPct float32
|
|
|
|
|
|
SilaLKN float32
|
|
|
|
|
|
SilaRKN float32
|
|
|
|
|
|
SumPercent float32
|
|
|
|
|
|
SumKN float32
|
|
|
|
|
|
ImbalancePercent float32
|
|
|
|
|
|
BiasPercent float32
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
type AlarmEvent struct {
|
|
|
|
|
|
TS time.Time
|
|
|
|
|
|
Severity string
|
|
|
|
|
|
Source string
|
|
|
|
|
|
Code string
|
|
|
|
|
|
State string
|
|
|
|
|
|
Message string
|
|
|
|
|
|
Value float64
|
|
|
|
|
|
Limit float64
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
type AppState struct {
|
|
|
|
|
|
sync.RWMutex
|
2026-04-16 17:02:59 +00:00
|
|
|
|
Connected bool
|
|
|
|
|
|
SilaL float32
|
|
|
|
|
|
SilaR float32
|
|
|
|
|
|
SilaLkN float32
|
|
|
|
|
|
SilaRkN float32
|
|
|
|
|
|
SumPercent float32
|
|
|
|
|
|
SumkN float32
|
|
|
|
|
|
ImbalancePercent float32
|
|
|
|
|
|
BiasPercent float32
|
|
|
|
|
|
LastUpdate time.Time
|
|
|
|
|
|
DroppedSamples uint64
|
2026-04-17 15:40:47 +00:00
|
|
|
|
DroppedEvents uint64
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type APIState struct {
|
2026-04-16 17:02:59 +00:00
|
|
|
|
Connected bool `json:"connected"`
|
|
|
|
|
|
SilaL float32 `json:"sila_l"`
|
|
|
|
|
|
SilaR float32 `json:"sila_r"`
|
|
|
|
|
|
SilaLkN float32 `json:"sila_l_kn"`
|
|
|
|
|
|
SilaRkN float32 `json:"sila_r_kn"`
|
|
|
|
|
|
SumPercent float32 `json:"sum_percent"`
|
|
|
|
|
|
SumkN float32 `json:"sum_kn"`
|
|
|
|
|
|
ImbalancePercent float32 `json:"imbalance_percent"`
|
|
|
|
|
|
BiasPercent float32 `json:"bias_percent"`
|
|
|
|
|
|
LastUpdate string `json:"last_update"`
|
|
|
|
|
|
DroppedSamples uint64 `json:"dropped_samples"`
|
2026-04-17 15:40:47 +00:00
|
|
|
|
DroppedEvents uint64 `json:"dropped_events"`
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type HistoryPoint struct {
|
|
|
|
|
|
Time string `json:"time"`
|
|
|
|
|
|
SilaL float32 `json:"sila_l"`
|
|
|
|
|
|
SilaR float32 `json:"sila_r"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type HistoryResponse struct {
|
|
|
|
|
|
Window string `json:"window"`
|
|
|
|
|
|
Points []HistoryPoint `json:"points"`
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
type TrendResponse struct {
|
|
|
|
|
|
Window string `json:"window"`
|
|
|
|
|
|
AvgPeak5m float32 `json:"avg_peak_5m"`
|
|
|
|
|
|
AvgPeak1h float32 `json:"avg_peak_1h"`
|
|
|
|
|
|
AvgImbalance5m float32 `json:"avg_imbalance_5m"`
|
|
|
|
|
|
AvgImbalance1h float32 `json:"avg_imbalance_1h"`
|
|
|
|
|
|
ForceDeltaPct float32 `json:"force_delta_pct"`
|
|
|
|
|
|
ImbalanceDeltaPct float32 `json:"imbalance_delta_pct"`
|
|
|
|
|
|
ForceDirection string `json:"force_direction"`
|
|
|
|
|
|
ImbalanceDirection string `json:"imbalance_direction"`
|
|
|
|
|
|
ProcessStability string `json:"process_stability"`
|
|
|
|
|
|
StabilityReason string `json:"stability_reason"`
|
|
|
|
|
|
ForceStdDev float32 `json:"force_stddev"`
|
|
|
|
|
|
ImbalanceStdDev float32 `json:"imbalance_stddev"`
|
|
|
|
|
|
SampleCount int `json:"sample_count"`
|
|
|
|
|
|
OldHalfCount int `json:"old_half_count"`
|
|
|
|
|
|
NewHalfCount int `json:"new_half_count"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AlarmEventAPI struct {
|
|
|
|
|
|
Time string `json:"time"`
|
|
|
|
|
|
Severity string `json:"severity"`
|
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
|
State string `json:"state"`
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
Value float64 `json:"value"`
|
|
|
|
|
|
Limit float64 `json:"limit"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AlarmResponse struct {
|
|
|
|
|
|
Events []AlarmEventAPI `json:"events"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
type PageData struct {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
Title string
|
|
|
|
|
|
Subtitle string
|
|
|
|
|
|
LeftLabel string
|
|
|
|
|
|
RightLabel string
|
|
|
|
|
|
UnitForce string
|
|
|
|
|
|
UnitPct string
|
|
|
|
|
|
MaxTonnage float64
|
|
|
|
|
|
WarningPercent float64
|
|
|
|
|
|
CriticalPercent float64
|
|
|
|
|
|
GaugeMaxPercent float64
|
|
|
|
|
|
ImbalanceWarningPercent float64
|
|
|
|
|
|
ImbalanceCriticalPercent float64
|
|
|
|
|
|
PollMs int
|
|
|
|
|
|
DefaultWindow string
|
2026-04-17 15:40:47 +00:00
|
|
|
|
DefaultTrendWindow string
|
2026-04-17 17:32:33 +00:00
|
|
|
|
|
|
|
|
|
|
ShowHeaderControls bool
|
|
|
|
|
|
ShowVerdict bool
|
|
|
|
|
|
ShowSummaryBar bool
|
|
|
|
|
|
ShowOverview bool
|
|
|
|
|
|
ShowIntelligence bool
|
|
|
|
|
|
ShowAlarmTimeline bool
|
|
|
|
|
|
ShowGauges bool
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ShowGaugeDigital bool
|
2026-04-17 17:32:33 +00:00
|
|
|
|
ShowTrendChart bool
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type NumericStats struct {
|
|
|
|
|
|
Avg float64
|
|
|
|
|
|
AvgSq float64
|
|
|
|
|
|
Min float64
|
|
|
|
|
|
Max float64
|
|
|
|
|
|
Count int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s NumericStats) StdDev() float64 {
|
|
|
|
|
|
if s.Count <= 1 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
v := s.AvgSq - (s.Avg * s.Avg)
|
|
|
|
|
|
if v < 0 {
|
|
|
|
|
|
v = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return math.Sqrt(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AlarmTracker struct {
|
|
|
|
|
|
sync.Mutex
|
|
|
|
|
|
PLCKnown bool
|
|
|
|
|
|
PLCConnected bool
|
|
|
|
|
|
LeftZone string
|
|
|
|
|
|
RightZone string
|
|
|
|
|
|
ImbZone string
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Package-level singletons
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
var (
|
2026-04-17 15:40:47 +00:00
|
|
|
|
cfg Config
|
2026-04-19 10:33:14 +00:00
|
|
|
|
cfgMu sync.RWMutex
|
2026-04-17 15:40:47 +00:00
|
|
|
|
state AppState
|
|
|
|
|
|
db *sql.DB
|
|
|
|
|
|
sampleCh chan Sample
|
|
|
|
|
|
alarmCh chan AlarmEvent
|
|
|
|
|
|
alarmTracker AlarmTracker
|
|
|
|
|
|
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
|
2026-04-17 17:32:33 +00:00
|
|
|
|
cachedUI []byte
|
2026-04-16 11:51:18 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-17 17:32:33 +00:00
|
|
|
|
// Force calculation
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
lp := float64(leftPercent)
|
|
|
|
|
|
rp := float64(rightPercent)
|
|
|
|
|
|
|
|
|
|
|
|
sumPct := (lp + rp) / 2.0
|
2026-04-17 15:44:57 +00:00
|
|
|
|
left := (lp / 100.0) * (maxTonnage / 2.0)
|
|
|
|
|
|
right := (rp / 100.0) * (maxTonnage / 2.0)
|
|
|
|
|
|
total := (sumPct / 100.0) * maxTonnage
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
|
|
|
|
|
return float32(left), float32(right), float32(sumPct), float32(total)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// State helpers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
func snapshotState() APIState {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
state.RLock()
|
|
|
|
|
|
defer state.RUnlock()
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
lastUpdate := ""
|
|
|
|
|
|
if !state.LastUpdate.IsZero() {
|
|
|
|
|
|
lastUpdate = state.LastUpdate.Format(time.RFC3339Nano)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return APIState{
|
2026-04-16 17:02:59 +00:00
|
|
|
|
Connected: state.Connected,
|
|
|
|
|
|
SilaL: state.SilaL,
|
|
|
|
|
|
SilaR: state.SilaR,
|
|
|
|
|
|
SilaLkN: state.SilaLkN,
|
|
|
|
|
|
SilaRkN: state.SilaRkN,
|
|
|
|
|
|
SumPercent: state.SumPercent,
|
|
|
|
|
|
SumkN: state.SumkN,
|
|
|
|
|
|
ImbalancePercent: state.ImbalancePercent,
|
|
|
|
|
|
BiasPercent: state.BiasPercent,
|
|
|
|
|
|
LastUpdate: lastUpdate,
|
|
|
|
|
|
DroppedSamples: state.DroppedSamples,
|
2026-04-17 15:40:47 +00:00
|
|
|
|
DroppedEvents: state.DroppedEvents,
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
func markDisconnected(reason string) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
state.Lock()
|
|
|
|
|
|
state.Connected = false
|
|
|
|
|
|
state.Unlock()
|
2026-04-17 15:40:47 +00:00
|
|
|
|
maybeLogPLCDisconnected(reason)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
func enqueueSample(s Sample) {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case sampleCh <- s:
|
|
|
|
|
|
default:
|
|
|
|
|
|
state.Lock()
|
|
|
|
|
|
state.DroppedSamples++
|
|
|
|
|
|
state.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
func enqueueAlarm(a AlarmEvent) {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case alarmCh <- a:
|
|
|
|
|
|
default:
|
|
|
|
|
|
state.Lock()
|
|
|
|
|
|
state.DroppedEvents++
|
|
|
|
|
|
state.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Database initialisation
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
|
|
|
|
|
|
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
found := false
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var cid int
|
2026-04-17 15:44:57 +00:00
|
|
|
|
var name, ctype string
|
2026-04-16 17:27:50 +00:00
|
|
|
|
var notNull int
|
|
|
|
|
|
var dfltValue sql.NullString
|
|
|
|
|
|
var pk int
|
|
|
|
|
|
|
|
|
|
|
|
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dfltValue, &pk); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if name == columnName {
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if found {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err = database.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, columnName, definition))
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
func initDatabase(dbPath string) (*sql.DB, error) {
|
|
|
|
|
|
dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs)
|
|
|
|
|
|
database, err := sql.Open("sqlite3", dsn)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
database.SetMaxOpenConns(1)
|
|
|
|
|
|
database.SetMaxIdleConns(1)
|
|
|
|
|
|
database.SetConnMaxLifetime(0)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
pragmas := []string{
|
|
|
|
|
|
"PRAGMA journal_mode=WAL;",
|
|
|
|
|
|
"PRAGMA synchronous=NORMAL;",
|
|
|
|
|
|
fmt.Sprintf("PRAGMA wal_autocheckpoint=%d;", cfg.DB.CheckpointPages),
|
|
|
|
|
|
fmt.Sprintf("PRAGMA busy_timeout=%d;", cfg.DB.BusyTimeoutMs),
|
|
|
|
|
|
"PRAGMA temp_store=MEMORY;",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, q := range pragmas {
|
|
|
|
|
|
if _, err := database.Exec(q); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("sqlite pragma failed (%s): %w", q, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
schema := `
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS samples (
|
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
|
ts DATETIME NOT NULL,
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ts_unix_ns INTEGER NOT NULL DEFAULT 0,
|
2026-04-16 15:46:07 +00:00
|
|
|
|
sila_l_pct REAL NOT NULL,
|
|
|
|
|
|
sila_r_pct REAL NOT NULL,
|
|
|
|
|
|
sila_l_kn REAL NOT NULL,
|
|
|
|
|
|
sila_r_kn REAL NOT NULL,
|
|
|
|
|
|
sum_pct REAL NOT NULL,
|
2026-04-16 17:02:59 +00:00
|
|
|
|
sum_kn REAL NOT NULL,
|
2026-04-16 17:27:50 +00:00
|
|
|
|
imbalance_pct REAL NOT NULL DEFAULT 0,
|
|
|
|
|
|
bias_pct REAL NOT NULL DEFAULT 0
|
2026-04-16 15:46:07 +00:00
|
|
|
|
);
|
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS alarm_events (
|
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
|
ts DATETIME NOT NULL,
|
|
|
|
|
|
ts_unix_ns INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
|
severity TEXT NOT NULL,
|
|
|
|
|
|
source TEXT NOT NULL,
|
|
|
|
|
|
code TEXT NOT NULL,
|
|
|
|
|
|
state TEXT NOT NULL,
|
|
|
|
|
|
message TEXT NOT NULL,
|
|
|
|
|
|
value REAL NOT NULL DEFAULT 0,
|
|
|
|
|
|
limit_value REAL NOT NULL DEFAULT 0
|
|
|
|
|
|
);
|
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_ns DESC);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
`
|
|
|
|
|
|
if _, err := database.Exec(schema); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("create schema: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if err := ensureColumn(database, "samples", "ts_unix_ns", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("ensure ts_unix_ns column: %w", err)
|
|
|
|
|
|
}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
if err := ensureColumn(database, "samples", "imbalance_pct", "REAL NOT NULL DEFAULT 0"); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("ensure imbalance_pct column: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := ensureColumn(database, "samples", "bias_pct", "REAL NOT NULL DEFAULT 0"); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("ensure bias_pct column: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns)`); err != nil {
|
|
|
|
|
|
_ = database.Close()
|
|
|
|
|
|
return nil, fmt.Errorf("create ts_unix_ns index: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := database.Exec(`
|
|
|
|
|
|
UPDATE samples
|
|
|
|
|
|
SET ts_unix_ns = CAST(strftime('%s', ts) AS INTEGER) * 1000000000
|
|
|
|
|
|
WHERE ts_unix_ns = 0 AND ts IS NOT NULL
|
|
|
|
|
|
`); err != nil {
|
|
|
|
|
|
log.Printf("warning: ts_unix_ns backfill failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := database.Exec(`
|
|
|
|
|
|
UPDATE alarm_events
|
|
|
|
|
|
SET ts_unix_ns = CAST(strftime('%s', ts) AS INTEGER) * 1000000000
|
|
|
|
|
|
WHERE ts_unix_ns = 0 AND ts IS NOT NULL
|
|
|
|
|
|
`); err != nil {
|
|
|
|
|
|
log.Printf("warning: alarm ts_unix_ns backfill failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
return database, nil
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-17 17:32:33 +00:00
|
|
|
|
// DB writer goroutines
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func startDBWriter(ctx context.Context, database *sql.DB) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
|
|
batch := make([]Sample, 0, cfg.DB.BatchSize)
|
|
|
|
|
|
|
|
|
|
|
|
flush := func() {
|
|
|
|
|
|
if len(batch) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := database.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("db begin failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
stmt, err := tx.Prepare(`
|
|
|
|
|
|
INSERT INTO samples (
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ts, ts_unix_ns, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn,
|
2026-04-16 17:02:59 +00:00
|
|
|
|
sum_pct, sum_kn, imbalance_pct, bias_pct
|
2026-04-17 15:40:47 +00:00
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
2026-04-16 17:02:59 +00:00
|
|
|
|
`)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
log.Printf("db prepare failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ok := true
|
|
|
|
|
|
for _, s := range batch {
|
|
|
|
|
|
_, err := stmt.Exec(
|
|
|
|
|
|
s.TS.UTC().Format(time.RFC3339Nano),
|
2026-04-17 15:40:47 +00:00
|
|
|
|
s.TS.UTC().UnixNano(),
|
2026-04-16 15:46:07 +00:00
|
|
|
|
s.SilaLPct,
|
|
|
|
|
|
s.SilaRPct,
|
|
|
|
|
|
s.SilaLKN,
|
|
|
|
|
|
s.SilaRKN,
|
|
|
|
|
|
s.SumPercent,
|
|
|
|
|
|
s.SumKN,
|
2026-04-16 17:02:59 +00:00
|
|
|
|
s.ImbalancePercent,
|
|
|
|
|
|
s.BiasPercent,
|
2026-04-16 15:46:07 +00:00
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
ok = false
|
|
|
|
|
|
log.Printf("db insert failed: %v", err)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = stmt.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
|
log.Printf("db commit failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
batch = batch[:0]
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
for {
|
|
|
|
|
|
select {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case s := <-sampleCh:
|
|
|
|
|
|
batch = append(batch, s)
|
|
|
|
|
|
default:
|
|
|
|
|
|
flush()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
case s := <-sampleCh:
|
|
|
|
|
|
batch = append(batch, s)
|
|
|
|
|
|
if len(batch) >= cfg.DB.BatchSize {
|
|
|
|
|
|
flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
|
flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
|
|
|
|
|
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
2026-04-17 15:40:47 +00:00
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
batch := make([]AlarmEvent, 0, cfg.DB.BatchSize)
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
flush := func() {
|
|
|
|
|
|
if len(batch) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := database.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("alarm db begin failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stmt, err := tx.Prepare(`
|
|
|
|
|
|
INSERT INTO alarm_events (
|
|
|
|
|
|
ts, ts_unix_ns, severity, source, code, state, message, value, limit_value
|
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
log.Printf("alarm db prepare failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ok := true
|
|
|
|
|
|
for _, a := range batch {
|
|
|
|
|
|
_, err := stmt.Exec(
|
|
|
|
|
|
a.TS.UTC().Format(time.RFC3339Nano),
|
|
|
|
|
|
a.TS.UTC().UnixNano(),
|
|
|
|
|
|
a.Severity,
|
|
|
|
|
|
a.Source,
|
|
|
|
|
|
a.Code,
|
|
|
|
|
|
a.State,
|
|
|
|
|
|
a.Message,
|
|
|
|
|
|
a.Value,
|
|
|
|
|
|
a.Limit,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
ok = false
|
|
|
|
|
|
log.Printf("alarm db insert failed: %v", err)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = stmt.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
|
log.Printf("alarm db commit failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
batch = batch[:0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case a := <-alarmCh:
|
|
|
|
|
|
batch = append(batch, a)
|
|
|
|
|
|
default:
|
|
|
|
|
|
flush()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
case a := <-alarmCh:
|
|
|
|
|
|
batch = append(batch, a)
|
2026-04-17 15:44:57 +00:00
|
|
|
|
if len(batch) >= cfg.DB.BatchSize {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
|
flush()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
func startDBCleanup(ctx context.Context, database *sql.DB) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
if cfg.DB.RetentionDays <= 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ticker := time.NewTicker(time.Duration(cfg.DB.CleanupIntervalHr) * time.Hour)
|
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
|
|
cleanup := func() {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
cutoffNs := time.Now().AddDate(0, 0, -cfg.DB.RetentionDays).UTC().UnixNano()
|
|
|
|
|
|
if _, err := database.Exec(`DELETE FROM samples WHERE ts_unix_ns > 0 AND ts_unix_ns < ?`, cutoffNs); err != nil {
|
|
|
|
|
|
log.Printf("db cleanup samples failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := database.Exec(`DELETE FROM alarm_events WHERE ts_unix_ns > 0 AND ts_unix_ns < ?`, cutoffNs); err != nil {
|
|
|
|
|
|
log.Printf("db cleanup alarms failed: %v", err)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cleanup()
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
|
cleanup()
|
|
|
|
|
|
}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Alarm zone helpers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
func zoneFromValue(value float64, warn, crit float64) string {
|
|
|
|
|
|
if value >= crit {
|
|
|
|
|
|
return "critical"
|
|
|
|
|
|
}
|
|
|
|
|
|
if value >= warn {
|
|
|
|
|
|
return "warning"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "ok"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sourceName(source string) string {
|
|
|
|
|
|
switch source {
|
|
|
|
|
|
case "force_left":
|
|
|
|
|
|
return "Left force"
|
|
|
|
|
|
case "force_right":
|
|
|
|
|
|
return "Right force"
|
|
|
|
|
|
case "imbalance":
|
|
|
|
|
|
return "Imbalance"
|
|
|
|
|
|
case "plc":
|
|
|
|
|
|
return "PLC"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return source
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func sourceLimit(source, zone string) float64 {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
config := getConfigSnapshot()
|
2026-04-17 15:40:47 +00:00
|
|
|
|
switch source {
|
|
|
|
|
|
case "imbalance":
|
|
|
|
|
|
if zone == "critical" {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
return config.Thresholds.ImbalanceCriticalPercent
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
if zone == "warning" {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
return config.Thresholds.ImbalanceWarningPercent
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if zone == "critical" {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
return config.Thresholds.CriticalPercent
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
if zone == "warning" {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
return config.Thresholds.WarningPercent
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maybeLogZoneChange(source, prev, curr string, value float64) {
|
|
|
|
|
|
if prev == curr {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
name := sourceName(source)
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
if prev == "" && curr == "ok" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch curr {
|
|
|
|
|
|
case "ok":
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: now,
|
|
|
|
|
|
Severity: "info",
|
|
|
|
|
|
Source: source,
|
|
|
|
|
|
Code: source + "_clear",
|
|
|
|
|
|
State: "clear",
|
|
|
|
|
|
Message: fmt.Sprintf("%s returned to OK", name),
|
|
|
|
|
|
Value: value,
|
|
|
|
|
|
Limit: 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
case "warning":
|
|
|
|
|
|
msg := fmt.Sprintf("%s entered WARNING zone", name)
|
|
|
|
|
|
if prev == "critical" {
|
|
|
|
|
|
msg = fmt.Sprintf("%s downgraded from CRITICAL to WARNING", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: now,
|
|
|
|
|
|
Severity: "warning",
|
|
|
|
|
|
Source: source,
|
|
|
|
|
|
Code: source + "_warning",
|
|
|
|
|
|
State: "active",
|
|
|
|
|
|
Message: msg,
|
|
|
|
|
|
Value: value,
|
|
|
|
|
|
Limit: sourceLimit(source, "warning"),
|
|
|
|
|
|
})
|
|
|
|
|
|
case "critical":
|
|
|
|
|
|
msg := fmt.Sprintf("%s entered CRITICAL zone", name)
|
|
|
|
|
|
if prev == "warning" {
|
|
|
|
|
|
msg = fmt.Sprintf("%s escalated from WARNING to CRITICAL", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: now,
|
|
|
|
|
|
Severity: "critical",
|
|
|
|
|
|
Source: source,
|
|
|
|
|
|
Code: source + "_critical",
|
|
|
|
|
|
State: "active",
|
|
|
|
|
|
Message: msg,
|
|
|
|
|
|
Value: value,
|
|
|
|
|
|
Limit: sourceLimit(source, "critical"),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func evaluateProcessAlarms(s Sample) {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
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)
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
alarmTracker.Lock()
|
|
|
|
|
|
defer alarmTracker.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
maybeLogZoneChange("force_left", alarmTracker.LeftZone, leftZone, float64(s.SilaLPct))
|
|
|
|
|
|
maybeLogZoneChange("force_right", alarmTracker.RightZone, rightZone, float64(s.SilaRPct))
|
|
|
|
|
|
maybeLogZoneChange("imbalance", alarmTracker.ImbZone, imbZone, float64(s.ImbalancePercent))
|
|
|
|
|
|
|
|
|
|
|
|
alarmTracker.LeftZone = leftZone
|
|
|
|
|
|
alarmTracker.RightZone = rightZone
|
|
|
|
|
|
alarmTracker.ImbZone = imbZone
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maybeLogPLCConnected() {
|
|
|
|
|
|
alarmTracker.Lock()
|
|
|
|
|
|
defer alarmTracker.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
if !alarmTracker.PLCKnown {
|
|
|
|
|
|
alarmTracker.PLCKnown = true
|
|
|
|
|
|
alarmTracker.PLCConnected = true
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: time.Now(),
|
|
|
|
|
|
Severity: "info",
|
|
|
|
|
|
Source: "plc",
|
|
|
|
|
|
Code: "plc_connected",
|
|
|
|
|
|
State: "info",
|
|
|
|
|
|
Message: "PLC connection established",
|
|
|
|
|
|
Value: 1,
|
|
|
|
|
|
Limit: 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !alarmTracker.PLCConnected {
|
|
|
|
|
|
alarmTracker.PLCConnected = true
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: time.Now(),
|
|
|
|
|
|
Severity: "info",
|
|
|
|
|
|
Source: "plc",
|
|
|
|
|
|
Code: "plc_restored",
|
|
|
|
|
|
State: "info",
|
|
|
|
|
|
Message: "PLC connection restored",
|
|
|
|
|
|
Value: 1,
|
|
|
|
|
|
Limit: 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maybeLogPLCDisconnected(reason string) {
|
|
|
|
|
|
alarmTracker.Lock()
|
|
|
|
|
|
defer alarmTracker.Unlock()
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
alarmTracker.PLCConnected = false
|
|
|
|
|
|
alarmTracker.LeftZone = ""
|
|
|
|
|
|
alarmTracker.RightZone = ""
|
|
|
|
|
|
alarmTracker.ImbZone = ""
|
|
|
|
|
|
|
|
|
|
|
|
enqueueAlarm(AlarmEvent{
|
|
|
|
|
|
TS: time.Now(),
|
|
|
|
|
|
Severity: "critical",
|
|
|
|
|
|
Source: "plc",
|
|
|
|
|
|
Code: "plc_disconnected",
|
|
|
|
|
|
State: "active",
|
|
|
|
|
|
Message: "PLC connection lost: " + reason,
|
|
|
|
|
|
Value: 0,
|
|
|
|
|
|
Limit: 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// PLC poller
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func startPLCPoller(ctx context.Context) {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
bootCfg := getConfigSnapshot()
|
|
|
|
|
|
pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond
|
|
|
|
|
|
reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second
|
2026-04-17 15:44:57 +00:00
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
for {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
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
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
if err := handler.Connect(); err != nil {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
markDisconnected(err.Error())
|
2026-04-19 10:33:14 +00:00
|
|
|
|
log.Printf("PLC connect failed: %v - retrying in %ds...", err, bootCfg.PLC.ReconnectDelaySec)
|
2026-04-17 15:44:57 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-time.After(reconnectDelay):
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
maybeLogPLCConnected()
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
client := gos7.NewClient(handler)
|
|
|
|
|
|
log.Println("PLC connected successfully")
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
buf := make([]byte, 8)
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
for {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
_ = handler.Close()
|
|
|
|
|
|
return
|
|
|
|
|
|
default:
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
if err := client.AGReadDB(bootCfg.PLC.DBNum, 0, 8, buf); err != nil {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
log.Printf("PLC read error: %v - reconnecting...", err)
|
2026-04-17 15:40:47 +00:00
|
|
|
|
markDisconnected(err.Error())
|
2026-04-16 11:51:18 +00:00
|
|
|
|
_ = handler.Close()
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var helper gos7.Helper
|
2026-04-16 15:03:02 +00:00
|
|
|
|
silaL := helper.GetRealAt(buf, 0)
|
|
|
|
|
|
silaR := helper.GetRealAt(buf, 4)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, getConfigSnapshot().Press.MaxTonnage)
|
2026-04-16 17:02:59 +00:00
|
|
|
|
imbalance := float32(math.Abs(float64(silaL - silaR)))
|
|
|
|
|
|
bias := silaL - silaR
|
2026-04-16 11:51:18 +00:00
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
state.Lock()
|
|
|
|
|
|
state.Connected = true
|
|
|
|
|
|
state.SilaL = silaL
|
|
|
|
|
|
state.SilaR = silaR
|
|
|
|
|
|
state.SilaLkN = leftKN
|
|
|
|
|
|
state.SilaRkN = rightKN
|
|
|
|
|
|
state.SumPercent = sumPercent
|
|
|
|
|
|
state.SumkN = sumKN
|
2026-04-16 17:02:59 +00:00
|
|
|
|
state.ImbalancePercent = imbalance
|
|
|
|
|
|
state.BiasPercent = bias
|
2026-04-16 11:51:18 +00:00
|
|
|
|
state.LastUpdate = now
|
2026-04-16 15:46:07 +00:00
|
|
|
|
state.Unlock()
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
sample := Sample{
|
2026-04-16 17:02:59 +00:00
|
|
|
|
TS: now,
|
|
|
|
|
|
SilaLPct: silaL,
|
|
|
|
|
|
SilaRPct: silaR,
|
|
|
|
|
|
SilaLKN: leftKN,
|
|
|
|
|
|
SilaRKN: rightKN,
|
|
|
|
|
|
SumPercent: sumPercent,
|
|
|
|
|
|
SumKN: sumKN,
|
|
|
|
|
|
ImbalancePercent: imbalance,
|
|
|
|
|
|
BiasPercent: bias,
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
evaluateProcessAlarms(sample)
|
|
|
|
|
|
enqueueSample(sample)
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
_ = handler.Close()
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-time.After(pollInterval):
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Query helpers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
func parseWindow(raw string) (time.Duration, string, error) {
|
|
|
|
|
|
s := strings.TrimSpace(strings.ToLower(raw))
|
|
|
|
|
|
if s == "" {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if strings.HasSuffix(s, "d") {
|
|
|
|
|
|
n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
|
|
|
|
|
if err != nil || n <= 0 {
|
|
|
|
|
|
return 0, "", fmt.Errorf("invalid day window")
|
|
|
|
|
|
}
|
|
|
|
|
|
d := time.Duration(n) * 24 * time.Hour
|
|
|
|
|
|
return d, s, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
d, err := time.ParseDuration(s)
|
|
|
|
|
|
if err != nil || d <= 0 {
|
|
|
|
|
|
return 0, "", fmt.Errorf("invalid window")
|
|
|
|
|
|
}
|
|
|
|
|
|
return d, s, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
func formatHistoryLabel(t time.Time, window time.Duration) string {
|
|
|
|
|
|
local := t.Local()
|
|
|
|
|
|
if window >= 12*time.Hour {
|
|
|
|
|
|
return local.Format("02.01 15:04")
|
|
|
|
|
|
}
|
|
|
|
|
|
return local.Format("15:04:05.000")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
func queryHistory(window time.Duration) ([]HistoryPoint, error) {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
cutoffNs := time.Now().Add(-window).UTC().UnixNano()
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts_unix_ns >= ? ORDER BY ts_unix_ns ASC`, cutoffNs)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
points := make([]HistoryPoint, 0, 1024)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var ts string
|
|
|
|
|
|
var l, r float64
|
|
|
|
|
|
if err := rows.Scan(&ts, &l, &r); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
t, err := time.Parse(time.RFC3339Nano, ts)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
points = append(points, HistoryPoint{
|
2026-04-16 17:02:59 +00:00
|
|
|
|
Time: formatHistoryLabel(t, window),
|
2026-04-16 15:46:07 +00:00
|
|
|
|
SilaL: float32(l),
|
|
|
|
|
|
SilaR: float32(r),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(points) <= cfg.DB.MaxChartPoints {
|
|
|
|
|
|
return points, nil
|
|
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
return downsamplePoints(points, cfg.DB.MaxChartPoints), nil
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint {
|
|
|
|
|
|
if len(points) <= max || max < 3 {
|
|
|
|
|
|
return points
|
|
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
out := make([]HistoryPoint, 0, max)
|
|
|
|
|
|
step := float64(len(points)-1) / float64(max-1)
|
|
|
|
|
|
used := make(map[int]struct{}, max)
|
|
|
|
|
|
|
|
|
|
|
|
for i := 0; i < max; i++ {
|
|
|
|
|
|
idx := int(float64(i) * step)
|
|
|
|
|
|
if idx >= len(points) {
|
|
|
|
|
|
idx = len(points) - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := used[idx]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
used[idx] = struct{}{}
|
|
|
|
|
|
out = append(out, points[idx])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
if len(out) == 0 {
|
|
|
|
|
|
return points
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
out[len(out)-1] = points[len(points)-1]
|
2026-04-16 15:46:07 +00:00
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
func validField(field string) (string, error) {
|
|
|
|
|
|
switch field {
|
|
|
|
|
|
case "sum_pct":
|
|
|
|
|
|
return "sum_pct", nil
|
|
|
|
|
|
case "imbalance_pct":
|
|
|
|
|
|
return "imbalance_pct", nil
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "", fmt.Errorf("invalid field")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
|
|
|
|
|
|
safeField, err := validField(field)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return NumericStats{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
query := fmt.Sprintf(`
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COALESCE(AVG(%[1]s), 0),
|
|
|
|
|
|
COALESCE(AVG(%[1]s * %[1]s), 0),
|
|
|
|
|
|
COALESCE(MIN(%[1]s), 0),
|
|
|
|
|
|
COALESCE(MAX(%[1]s), 0),
|
|
|
|
|
|
COUNT(*)
|
|
|
|
|
|
FROM samples
|
|
|
|
|
|
WHERE ts_unix_ns >= ? AND ts_unix_ns < ?
|
|
|
|
|
|
`, safeField)
|
|
|
|
|
|
|
|
|
|
|
|
var stats NumericStats
|
|
|
|
|
|
err = db.QueryRow(query, fromNs, toNs).Scan(&stats.Avg, &stats.AvgSq, &stats.Min, &stats.Max, &stats.Count)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return NumericStats{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return stats, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Trend / stability classification
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if oldCount < 3 || newCount < 3 {
|
|
|
|
|
|
return "insufficient_data"
|
|
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
if math.Abs(delta) < stableThreshold {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
return "stable"
|
|
|
|
|
|
}
|
|
|
|
|
|
if delta > 0 {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
return posLabel
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
2026-04-17 15:44:57 +00:00
|
|
|
|
return negLabel
|
2026-04-17 15:40:47 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) {
|
|
|
|
|
|
if sampleCount < 8 {
|
|
|
|
|
|
return "insufficient_data", "Too few samples in selected trend window"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
config := getConfigSnapshot()
|
|
|
|
|
|
|
|
|
|
|
|
if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= config.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
|
|
|
|
|
|
if avgImb5m >= config.Thresholds.ImbalanceCriticalPercent {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
return "unstable", "High average imbalance in last 5 minutes"
|
|
|
|
|
|
}
|
|
|
|
|
|
if math.Abs(forceDelta) >= 8.0 {
|
|
|
|
|
|
return "unstable", "Average peak force is drifting fast"
|
|
|
|
|
|
}
|
|
|
|
|
|
if forceStd >= 6.0 {
|
|
|
|
|
|
return "unstable", "Force variation is too high"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "unstable", "Imbalance variation is too high"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
|
|
|
|
|
|
if avgImb5m >= config.Thresholds.ImbalanceWarningPercent {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
return "caution", "Imbalance is trending above warning region"
|
|
|
|
|
|
}
|
|
|
|
|
|
if math.Abs(forceDelta) >= 3.0 {
|
|
|
|
|
|
return "caution", "Average force is drifting"
|
|
|
|
|
|
}
|
|
|
|
|
|
if forceStd >= 3.0 {
|
|
|
|
|
|
return "caution", "Force is less repeatable than normal"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "caution", "Imbalance repeatability is degrading"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "stable", "Process variation is low"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildTrendResponse(window time.Duration, label string) (TrendResponse, error) {
|
|
|
|
|
|
nowNs := time.Now().UTC().UnixNano()
|
|
|
|
|
|
windowNs := window.Nanoseconds()
|
|
|
|
|
|
startNs := nowNs - windowNs
|
|
|
|
|
|
midNs := startNs + (windowNs / 2)
|
|
|
|
|
|
|
|
|
|
|
|
force5m, err := queryNumericStats("sum_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
force1h, err := queryNumericStats("sum_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
imb5m, err := queryNumericStats("imbalance_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
imb1h, err := queryNumericStats("imbalance_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
forceOld, err := queryNumericStats("sum_pct", startNs, midNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
forceNew, err := queryNumericStats("sum_pct", midNs, nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
imbOld, err := queryNumericStats("imbalance_pct", startNs, midNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
imbNew, err := queryNumericStats("imbalance_pct", midNs, nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
forceDelta := forceNew.Avg - forceOld.Avg
|
|
|
|
|
|
imbDelta := imbNew.Avg - imbOld.Avg
|
2026-04-17 15:44:57 +00:00
|
|
|
|
|
|
|
|
|
|
forceDirection := classifyDirection(forceDelta, forceOld.Count, forceNew.Count, 1.0, "rising", "falling")
|
|
|
|
|
|
imbDirection := classifyDirection(imbDelta, imbOld.Count, imbNew.Count, 0.5, "worsening", "improving")
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
fullWindowForce, err := queryNumericStats("sum_pct", startNs, nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
fullWindowImb, err := queryNumericStats("imbalance_pct", startNs, nowNs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return TrendResponse{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stability, reason := classifyProcessStability(
|
|
|
|
|
|
fullWindowForce.StdDev(),
|
|
|
|
|
|
fullWindowImb.StdDev(),
|
|
|
|
|
|
forceDelta,
|
|
|
|
|
|
imb5m.Avg,
|
|
|
|
|
|
fullWindowForce.Count,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return TrendResponse{
|
|
|
|
|
|
Window: label,
|
|
|
|
|
|
AvgPeak5m: float32(force5m.Avg),
|
|
|
|
|
|
AvgPeak1h: float32(force1h.Avg),
|
|
|
|
|
|
AvgImbalance5m: float32(imb5m.Avg),
|
|
|
|
|
|
AvgImbalance1h: float32(imb1h.Avg),
|
|
|
|
|
|
ForceDeltaPct: float32(forceDelta),
|
|
|
|
|
|
ImbalanceDeltaPct: float32(imbDelta),
|
|
|
|
|
|
ForceDirection: forceDirection,
|
|
|
|
|
|
ImbalanceDirection: imbDirection,
|
|
|
|
|
|
ProcessStability: stability,
|
|
|
|
|
|
StabilityReason: reason,
|
|
|
|
|
|
ForceStdDev: float32(fullWindowForce.StdDev()),
|
|
|
|
|
|
ImbalanceStdDev: float32(fullWindowImb.StdDev()),
|
|
|
|
|
|
SampleCount: fullWindowForce.Count,
|
|
|
|
|
|
OldHalfCount: forceOld.Count,
|
|
|
|
|
|
NewHalfCount: forceNew.Count,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) {
|
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
|
limit = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
if limit > 100 {
|
|
|
|
|
|
limit = 100
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rows, err := db.Query(`
|
|
|
|
|
|
SELECT ts, severity, source, state, message, value, limit_value
|
|
|
|
|
|
FROM alarm_events
|
|
|
|
|
|
ORDER BY ts_unix_ns DESC
|
|
|
|
|
|
LIMIT ?
|
|
|
|
|
|
`, limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
events := make([]AlarmEventAPI, 0, limit)
|
|
|
|
|
|
for rows.Next() {
|
2026-04-17 15:44:57 +00:00
|
|
|
|
var ts, severity, source, state, message string
|
|
|
|
|
|
var value, limitValue float64
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t, err := time.Parse(time.RFC3339Nano, ts)
|
|
|
|
|
|
displayTime := ts
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
displayTime = t.Local().Format("02.01.2006 15:04:05")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events = append(events, AlarmEventAPI{
|
|
|
|
|
|
Time: displayTime,
|
|
|
|
|
|
Severity: severity,
|
|
|
|
|
|
Source: source,
|
|
|
|
|
|
State: state,
|
|
|
|
|
|
Message: message,
|
|
|
|
|
|
Value: value,
|
|
|
|
|
|
Limit: limitValue,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return events, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// HTTP handlers
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
func apiData(w http.ResponseWriter, r *http.Request) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
|
_ = json.NewEncoder(w).Encode(snapshotState())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func apiHistory(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
window, label, err := parseWindow(r.URL.Query().Get("window"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, "invalid window", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
points, err := queryHistory(window)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, "history query failed", http.StatusInternalServerError)
|
|
|
|
|
|
log.Printf("history query failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
2026-04-16 15:46:07 +00:00
|
|
|
|
_ = json.NewEncoder(w).Encode(HistoryResponse{
|
|
|
|
|
|
Window: label,
|
|
|
|
|
|
Points: points,
|
|
|
|
|
|
})
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
func apiTrend(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
window, label, err := parseWindow(r.URL.Query().Get("window"))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, "invalid trend window", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := buildTrendResponse(window, label)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, "trend query failed", http.StatusInternalServerError)
|
|
|
|
|
|
log.Printf("trend query failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func apiAlarms(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
limit := 20
|
|
|
|
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
|
|
|
|
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
|
|
|
|
|
limit = n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events, err := queryAlarmEvents(limit)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, "alarm query failed", http.StatusInternalServerError)
|
|
|
|
|
|
log.Printf("alarm query failed: %v", err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
|
_ = json.NewEncoder(w).Encode(AlarmResponse{
|
|
|
|
|
|
Events: events,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
2026-04-19 10:33:14 +00:00
|
|
|
|
cfgMu.RLock()
|
|
|
|
|
|
payload := cachedUI
|
|
|
|
|
|
cfgMu.RUnlock()
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
|
_, _ = w.Write(payload)
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// main
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
func main() {
|
|
|
|
|
|
wd, err := os.Getwd()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Fatalf("failed to get working directory: %v", err)
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
configPath := filepath.Join(wd, "config.yaml")
|
|
|
|
|
|
cfg, err = loadOrCreateConfig(configPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Fatalf("failed to load config: %v", err)
|
|
|
|
|
|
}
|
2026-04-19 10:33:14 +00:00
|
|
|
|
if err := validateConfig(cfg); err != nil {
|
|
|
|
|
|
log.Fatalf("invalid config: %v", err)
|
|
|
|
|
|
}
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
dbPath := cfg.DB.Path
|
|
|
|
|
|
if !filepath.IsAbs(dbPath) {
|
|
|
|
|
|
dbPath = filepath.Join(wd, dbPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
db, err = initDatabase(dbPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Fatalf("failed to init database: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer db.Close()
|
|
|
|
|
|
|
|
|
|
|
|
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
|
2026-04-17 17:32:33 +00:00
|
|
|
|
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize)
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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",
|
2026-04-16 15:03:02 +00:00
|
|
|
|
cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
|
2026-04-17 15:44:57 +00:00
|
|
|
|
log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)
|
|
|
|
|
|
|
|
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
|
|
|
|
defer stop()
|
|
|
|
|
|
|
2026-04-19 10:33:14 +00:00
|
|
|
|
if err := startConfigWatcher(ctx, configPath); err != nil {
|
|
|
|
|
|
log.Printf("config watch disabled: %v", err)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Config watcher enabled for %s", configPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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) }()
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
staticRoot, err := fs.Sub(staticFiles, "static")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Fatalf("failed to mount embedded static files: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:44:57 +00:00
|
|
|
|
mux := http.NewServeMux()
|
2026-04-17 17:32:33 +00:00
|
|
|
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
2026-04-17 15:44:57 +00:00
|
|
|
|
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")
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const uiHTML = `<!DOCTYPE html>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<title>{{.Title}}</title>
|
2026-04-17 07:57:32 +00:00
|
|
|
|
<script src="/static/tailwind.min.js"></script>
|
|
|
|
|
|
<script src="/static/chart.umd.min.js"></script>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<style>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
:root {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
--bg1: #050816;
|
|
|
|
|
|
--bg2: #0b1224;
|
|
|
|
|
|
--panel: rgba(255,255,255,0.055);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
--body-text: #f4f4f5;
|
|
|
|
|
|
--button-bg: rgba(255,255,255,0.05);
|
|
|
|
|
|
--button-border: rgba(255,255,255,0.10);
|
|
|
|
|
|
--button-text: #e4e4e7;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; }
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
html, body {
|
|
|
|
|
|
min-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
body {
|
2026-04-17 07:57:32 +00:00
|
|
|
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
background:
|
2026-04-16 15:46:07 +00:00
|
|
|
|
radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
|
|
|
|
|
|
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
|
|
|
|
|
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
color: var(--body-text);
|
|
|
|
|
|
transition: background 180ms ease, color 180ms ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] {
|
|
|
|
|
|
--bg1: #eef4ff;
|
|
|
|
|
|
--bg2: #f8fafc;
|
|
|
|
|
|
--panel: rgba(255,255,255,0.80);
|
|
|
|
|
|
--body-text: #0f172a;
|
|
|
|
|
|
--button-bg: rgba(255,255,255,0.88);
|
|
|
|
|
|
--button-border: rgba(15,23,42,0.10);
|
|
|
|
|
|
--button-text: #0f172a;
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
|
|
|
|
|
|
radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
|
|
|
|
|
|
linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
.glass {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
background: var(--panel);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
backdrop-filter: blur(14px);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(14px);
|
|
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
body[data-theme="light"] .glass,
|
|
|
|
|
|
body[data-theme="light"] .summary-card,
|
|
|
|
|
|
body[data-theme="light"] .intel-card,
|
|
|
|
|
|
body[data-theme="light"] .verdict-card {
|
|
|
|
|
|
border-color: rgba(15,23,42,0.10) !important;
|
|
|
|
|
|
box-shadow: 0 10px 28px rgba(15,23,42,0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .bg-zinc-900\/60,
|
|
|
|
|
|
body[data-theme="light"] .bg-white\/5 {
|
|
|
|
|
|
background: rgba(255,255,255,0.86) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .border-zinc-800,
|
|
|
|
|
|
body[data-theme="light"] .border-white\/10 {
|
|
|
|
|
|
border-color: rgba(15,23,42,0.10) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-zinc-100,
|
|
|
|
|
|
body[data-theme="light"] .text-zinc-200 {
|
|
|
|
|
|
color: #0f172a !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-zinc-300 {
|
|
|
|
|
|
color: #1e293b !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-zinc-400,
|
|
|
|
|
|
body[data-theme="light"] .text-zinc-500 {
|
|
|
|
|
|
color: #475569 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-emerald-300 {
|
|
|
|
|
|
color: #059669 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-emerald-400 {
|
|
|
|
|
|
color: #10b981 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-sky-100,
|
|
|
|
|
|
body[data-theme="light"] .text-sky-200 {
|
|
|
|
|
|
color: #0369a1 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-violet-100,
|
|
|
|
|
|
body[data-theme="light"] .text-violet-200 {
|
|
|
|
|
|
color: #7c3aed !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-amber-200 {
|
|
|
|
|
|
color: #b45309 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-sky-400 {
|
|
|
|
|
|
color: #0284c7 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-violet-400 {
|
|
|
|
|
|
color: #7c3aed !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-red-400 {
|
|
|
|
|
|
color: #dc2626 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .text-yellow-400 {
|
|
|
|
|
|
color: #b45309 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-height: 44px;
|
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
border: 1px solid var(--button-border);
|
|
|
|
|
|
background: var(--button-bg);
|
|
|
|
|
|
color: var(--button-text);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
transition: 160ms ease;
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
|
-webkit-backdrop-filter: blur(10px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-btn:hover {
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
box-shadow: 0 8px 20px rgba(0,0,0,0.10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
.soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); }
|
|
|
|
|
|
.soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); }
|
|
|
|
|
|
.soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); }
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
.gauge-header-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 24px;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-head.with-digital {
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-head-copy {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-head-copy.with-digital {
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-digital {
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
.gauge-container {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
2026-04-19 07:22:16 +00:00
|
|
|
|
max-width: 720px;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
.gauge-container.no-digital {
|
|
|
|
|
|
height: clamp(430px, 48vw, 560px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-container.with-digital {
|
|
|
|
|
|
height: clamp(360px, 42vw, 500px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
.gauge-canvas {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.window-btn.active,
|
|
|
|
|
|
.trend-window-btn.active {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
border-color: rgba(34,211,238,0.9);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
background: rgba(34,211,238,0.14);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
body[data-theme="light"] .window-btn.active,
|
|
|
|
|
|
body[data-theme="light"] .trend-window-btn.active {
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
background: rgba(14,165,233,0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
.chart-wrap {
|
|
|
|
|
|
width: min(92vw, 1800px);
|
|
|
|
|
|
margin: 0 auto;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.summary-card,
|
|
|
|
|
|
.intel-card,
|
|
|
|
|
|
.verdict-card {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
|
border: 1px solid rgba(255,255,255,0.09);
|
|
|
|
|
|
background: rgba(255,255,255,0.04);
|
|
|
|
|
|
transition: 180ms ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.intel-card {
|
|
|
|
|
|
min-height: 126px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-card.ok,
|
|
|
|
|
|
.intel-card.ok,
|
|
|
|
|
|
.verdict-card.ok {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
border-color: rgba(34,197,94,0.35);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset, 0 0 26px rgba(34,197,94,0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.summary-card.warning,
|
|
|
|
|
|
.intel-card.warning,
|
|
|
|
|
|
.verdict-card.warning {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
border-color: rgba(234,179,8,0.35);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(234,179,8,0.08) inset, 0 0 26px rgba(234,179,8,0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.summary-card.critical,
|
|
|
|
|
|
.intel-card.critical,
|
|
|
|
|
|
.verdict-card.critical {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
border-color: rgba(239,68,68,0.35);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(239,68,68,0.08) inset, 0 0 26px rgba(239,68,68,0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
.summary-card.neutral,
|
|
|
|
|
|
.intel-card.neutral,
|
|
|
|
|
|
.verdict-card.neutral {
|
2026-04-16 17:27:50 +00:00
|
|
|
|
border-color: rgba(113,113,122,0.35);
|
|
|
|
|
|
box-shadow: 0 0 0 1px rgba(113,113,122,0.08) inset;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-dot {
|
|
|
|
|
|
width: 14px;
|
|
|
|
|
|
height: 14px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-dot.ok {
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
box-shadow: 0 0 14px rgba(16,185,129,0.55);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-dot.warning {
|
|
|
|
|
|
background: #f59e0b;
|
|
|
|
|
|
box-shadow: 0 0 14px rgba(245,158,11,0.55);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-dot.critical {
|
|
|
|
|
|
background: #ef4444;
|
|
|
|
|
|
box-shadow: 0 0 14px rgba(239,68,68,0.55);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-dot.neutral {
|
|
|
|
|
|
background: #71717a;
|
|
|
|
|
|
box-shadow: 0 0 12px rgba(113,113,122,0.35);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.summary-status.ok { color: #34d399; }
|
|
|
|
|
|
.summary-status.warning { color: #facc15; }
|
|
|
|
|
|
.summary-status.critical { color: #f87171; }
|
|
|
|
|
|
.summary-status.neutral { color: #a1a1aa; }
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
.intel-value {
|
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
|
font-size: 2rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
color: #f4f4f5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .intel-value {
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.intel-sub {
|
|
|
|
|
|
font-size: 0.83rem;
|
|
|
|
|
|
color: #a1a1aa;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
line-height: 1.35;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.intel-kpi {
|
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.dir-up { color: #facc15; }
|
|
|
|
|
|
.dir-down { color: #34d399; }
|
|
|
|
|
|
.dir-flat { color: #a1a1aa; }
|
|
|
|
|
|
.dir-bad { color: #f87171; }
|
|
|
|
|
|
|
|
|
|
|
|
.mini-mono {
|
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.severity-pill {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
min-width: 86px;
|
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.severity-pill.info {
|
|
|
|
|
|
background: rgba(59,130,246,0.12);
|
|
|
|
|
|
color: #93c5fd;
|
|
|
|
|
|
border: 1px solid rgba(59,130,246,0.22);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.severity-pill.warning {
|
|
|
|
|
|
background: rgba(245,158,11,0.12);
|
|
|
|
|
|
color: #fde68a;
|
|
|
|
|
|
border: 1px solid rgba(245,158,11,0.22);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.severity-pill.critical {
|
|
|
|
|
|
background: rgba(239,68,68,0.12);
|
|
|
|
|
|
color: #fca5a5;
|
|
|
|
|
|
border: 1px solid rgba(239,68,68,0.22);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .severity-pill.info {
|
|
|
|
|
|
color: #1d4ed8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .severity-pill.warning {
|
|
|
|
|
|
color: #b45309;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .severity-pill.critical {
|
|
|
|
|
|
color: #dc2626;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.alarm-table tbody tr:hover {
|
|
|
|
|
|
background: rgba(255,255,255,0.03);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body[data-theme="light"] .alarm-table tbody tr:hover {
|
|
|
|
|
|
background: rgba(15,23,42,0.03);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.limit-line-note {
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
color: #a1a1aa;
|
|
|
|
|
|
}
|
2026-04-17 17:52:51 +00:00
|
|
|
|
|
|
|
|
|
|
.process-offline {
|
|
|
|
|
|
opacity: 0.35;
|
|
|
|
|
|
filter: grayscale(1) blur(1.5px);
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
transition: opacity 180ms ease, filter 180ms ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.process-online {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
filter: none;
|
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
|
user-select: auto;
|
|
|
|
|
|
transition: opacity 180ms ease, filter 180ms ease;
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<body data-theme="dark">
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
|
|
|
|
|
|
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-04-17 07:57:32 +00:00
|
|
|
|
<span class="text-2xl">⚠️</span>
|
2026-04-16 17:27:50 +00:00
|
|
|
|
<span id="alarm-text">CRITICAL ALARM ACTIVE</span>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between mb-8">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div>
|
2026-04-17 07:57:32 +00:00
|
|
|
|
<h1 class="text-4xl md:text-5xl xl:text-6xl font-semibold tracking-tighter bg-gradient-to-r from-sky-300 to-violet-300 bg-clip-text text-transparent">{{.Title}}</h1>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<p class="text-zinc-400 mt-2 text-base md:text-lg">{{.Subtitle}}</p>
|
|
|
|
|
|
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="flex flex-col gap-4 xl:items-end">
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowHeaderControls}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="flex flex-wrap gap-3 justify-end">
|
|
|
|
|
|
<button id="theme-toggle" class="control-btn" type="button">Light theme</button>
|
|
|
|
|
|
<button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
|
|
|
|
|
|
<span id="status-text" class="font-semibold text-lg text-red-400">Disconnected</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="hidden md:block h-8 w-px bg-zinc-700"></div>
|
|
|
|
|
|
<div id="last-update" class="font-mono text-zinc-400 text-sm">Last update: --:--:--.---</div>
|
|
|
|
|
|
<div class="hidden md:block h-8 w-px bg-zinc-700"></div>
|
|
|
|
|
|
<div class="font-mono text-zinc-500 text-sm">Dropped S: <span id="dropped-samples">0</span> | E: <span id="dropped-events">0</span></div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-17 17:52:51 +00:00
|
|
|
|
<div id="process-content">
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowVerdict}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div id="verdict-card" class="verdict-card neutral mb-8">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
|
|
|
|
|
|
<div id="verdict-status" class="text-3xl md:text-4xl font-bold mt-2 text-zinc-200">NO DATA</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowSummaryBar}}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
|
|
|
|
|
|
<div id="summary-force-card" class="summary-card neutral">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div id="summary-force-dot" class="summary-dot neutral"></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE</div>
|
|
|
|
|
|
<div id="summary-force-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="summary-force-value" class="font-mono text-zinc-200 text-lg">--</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="summary-imbalance-card" class="summary-card neutral">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div id="summary-imbalance-dot" class="summary-dot neutral"></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE</div>
|
|
|
|
|
|
<div id="summary-imbalance-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="summary-imbalance-value" class="font-mono text-zinc-200 text-lg">--</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="summary-plc-card" class="summary-card neutral">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div id="summary-plc-dot" class="summary-dot neutral"></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PLC</div>
|
|
|
|
|
|
<div id="summary-plc-text" class="summary-status neutral font-semibold mt-1">OFFLINE</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowOverview}}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div class="mt-2 flex items-end gap-4">
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">TOTAL %</div>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">IMBALANCE</div>
|
|
|
|
|
|
<div class="text-3xl font-mono font-bold text-amber-200 mt-1"><span id="imbalance-pct">0.0</span> {{.UnitPct}}</div>
|
|
|
|
|
|
<div class="text-xs text-zinc-500 mt-2 font-mono">abs(L - R)</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
|
|
|
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">BIAS</div>
|
|
|
|
|
|
<div class="text-3xl font-mono font-bold text-violet-200 mt-1"><span id="bias-pct">0.0</span> {{.UnitPct}}</div>
|
|
|
|
|
|
<div class="text-xs text-zinc-500 mt-2 font-mono">L - R</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
|
2026-04-16 17:27:50 +00:00
|
|
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
|
|
|
|
|
|
<div class="text-sm font-mono text-zinc-300 mt-2">Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}</div>
|
|
|
|
|
|
<div class="text-xs text-zinc-500 mt-2 font-mono">Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}</div>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowIntelligence}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
|
|
|
|
|
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="text-2xl md:text-3xl font-semibold">Drift / Deterioration Intelligence</h2>
|
|
|
|
|
|
<div class="text-zinc-400 mt-1 text-sm md:text-base">Averages, drift direction, imbalance deterioration and process stability</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<button class="trend-window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
|
|
|
|
|
|
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
|
|
|
|
|
|
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30m">30m</button>
|
|
|
|
|
|
<button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
|
|
|
|
|
|
<div class="flex items-center gap-2 ml-1">
|
|
|
|
|
|
<input id="custom-trend-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
|
|
|
|
|
|
<button id="apply-trend-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-5 gap-4">
|
|
|
|
|
|
<div id="intel-avg5-card" class="intel-card neutral">
|
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 5 MIN</div>
|
|
|
|
|
|
<div id="intel-avg5-value" class="intel-value mt-3">--</div>
|
|
|
|
|
|
<div id="intel-avg5-sub" class="intel-sub">No data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="intel-avg1h-card" class="intel-card neutral">
|
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 1 HOUR</div>
|
|
|
|
|
|
<div id="intel-avg1h-value" class="intel-value mt-3">--</div>
|
|
|
|
|
|
<div id="intel-avg1h-sub" class="intel-sub">No data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="intel-force-card" class="intel-card neutral">
|
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE TREND</div>
|
|
|
|
|
|
<div id="intel-force-value" class="intel-value mt-3">--</div>
|
|
|
|
|
|
<div id="intel-force-sub" class="intel-sub">No data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="intel-imb-card" class="intel-card neutral">
|
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE TREND</div>
|
|
|
|
|
|
<div id="intel-imb-value" class="intel-value mt-3">--</div>
|
|
|
|
|
|
<div id="intel-imb-sub" class="intel-sub">No data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="intel-stability-card" class="intel-card neutral">
|
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
|
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PROCESS STABILITY</div>
|
|
|
|
|
|
<div id="intel-stability-value" class="intel-value mt-3">--</div>
|
|
|
|
|
|
<div id="intel-stability-sub" class="intel-sub">No data</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowAlarmTimeline}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
|
|
|
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="text-2xl md:text-3xl font-semibold">Event / Alarm Timeline</h2>
|
|
|
|
|
|
<div class="text-zinc-400 mt-1 text-sm md:text-base">Recent transitions show exactly when the process began drifting, overloading, losing balance, or losing PLC communication</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="limit-line-note">Newest events first • clear events included</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="overflow-x-auto">
|
|
|
|
|
|
<table class="alarm-table w-full text-sm">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr class="text-left text-zinc-500 border-b border-white/10">
|
|
|
|
|
|
<th class="py-3 pr-4">Time</th>
|
|
|
|
|
|
<th class="py-3 pr-4">Severity</th>
|
|
|
|
|
|
<th class="py-3 pr-4">Source</th>
|
|
|
|
|
|
<th class="py-3 pr-4">Event</th>
|
|
|
|
|
|
<th class="py-3 pr-4 text-right">Value</th>
|
|
|
|
|
|
<th class="py-3 text-right">Limit</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody id="alarm-table-body" class="text-zinc-200">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowGauges}}
|
2026-04-19 10:33:14 +00:00
|
|
|
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<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">
|
|
|
|
|
|
<div class="gauge-head with-digital">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div class="gauge-head-copy with-digital">
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div id="digital-l" class="gauge-digital">
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="text-xl text-sky-400 mt-1">{{.UnitPct}}</div>
|
|
|
|
|
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-19 07:22:16 +00:00
|
|
|
|
{{else}}
|
|
|
|
|
|
<div class="gauge-head">
|
|
|
|
|
|
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
|
|
|
|
|
<div class="gauge-head-copy">
|
|
|
|
|
|
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
|
|
|
|
|
|
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<canvas id="gaugeL" class="gauge-canvas"></canvas>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div id="card-r" 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">
|
|
|
|
|
|
<div class="gauge-head with-digital">
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div class="gauge-head-copy with-digital">
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div id="digital-r" class="gauge-digital">
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<div class="text-xl text-violet-400 mt-1">{{.UnitPct}}</div>
|
|
|
|
|
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-19 07:22:16 +00:00
|
|
|
|
{{else}}
|
|
|
|
|
|
<div class="gauge-head">
|
|
|
|
|
|
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
|
|
|
|
|
<div class="gauge-head-copy">
|
|
|
|
|
|
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
|
|
|
|
|
|
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-19 07:22:16 +00:00
|
|
|
|
<div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
|
2026-04-16 15:03:02 +00:00
|
|
|
|
<canvas id="gaugeR" class="gauge-canvas"></canvas>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{if .ShowTrendChart}}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="chart-wrap">
|
|
|
|
|
|
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
|
|
|
|
|
|
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
|
|
|
|
|
<div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
<h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
|
2026-04-17 15:40:47 +00:00
|
|
|
|
<div class="text-zinc-400 mt-1 text-sm md:text-base">Piezo peak/stroke history from SQLite with visible warning and critical limits</div>
|
2026-04-16 15:46:07 +00:00
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<button class="window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30s">30s</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1m">1m</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="8h">8h</button>
|
|
|
|
|
|
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="24h">24h</button>
|
|
|
|
|
|
<div class="flex items-center gap-2 ml-1">
|
|
|
|
|
|
<input id="custom-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
|
|
|
|
|
|
<button id="apply-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
<div class="h-[52vh] min-h-[420px] max-h-[760px]">
|
|
|
|
|
|
<canvas id="lineChart"></canvas>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
</div>
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-17 17:32:33 +00:00
|
|
|
|
{{end}}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const WARNING_PERCENT = {{.WarningPercent}};
|
|
|
|
|
|
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
|
|
|
|
|
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}};
|
|
|
|
|
|
const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}};
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const UNIT_FORCE = '{{.UnitForce}}';
|
|
|
|
|
|
const UNIT_PCT = '{{.UnitPct}}';
|
2026-04-16 15:46:07 +00:00
|
|
|
|
const POLL_MS = {{.PollMs}};
|
|
|
|
|
|
const DEFAULT_WINDOW = '{{.DefaultWindow}}';
|
2026-04-17 15:40:47 +00:00
|
|
|
|
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
|
|
|
|
|
|
const STALE_MS = Math.max(POLL_MS * 4, 2500);
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
const SHOW_HEADER_CONTROLS = {{if .ShowHeaderControls}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_VERDICT = {{if .ShowVerdict}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_SUMMARY_BAR = {{if .ShowSummaryBar}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_OVERVIEW = {{if .ShowOverview}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_INTELLIGENCE = {{if .ShowIntelligence}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_ALARM_TIMELINE = {{if .ShowAlarmTimeline}}true{{else}}false{{end}};
|
|
|
|
|
|
const SHOW_GAUGES = {{if .ShowGauges}}true{{else}}false{{end}};
|
2026-04-19 07:22:16 +00:00
|
|
|
|
const SHOW_GAUGE_DIGITAL = {{if .ShowGaugeDigital}}true{{else}}false{{end}};
|
2026-04-17 17:32:33 +00:00
|
|
|
|
const SHOW_TREND_CHART = {{if .ShowTrendChart}}true{{else}}false{{end}};
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
const START_ANGLE = Math.PI * 0.75;
|
|
|
|
|
|
const END_ANGLE = Math.PI * 2.25;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
let lineChart = null;
|
|
|
|
|
|
let latestData = null;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
let currentWindow = DEFAULT_WINDOW;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
let currentTrendWindow = DEFAULT_TREND_WINDOW;
|
|
|
|
|
|
let currentTheme = 'dark';
|
2026-04-16 15:46:07 +00:00
|
|
|
|
let historyBusy = false;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
let trendBusy = false;
|
|
|
|
|
|
let alarmsBusy = false;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
|
|
|
|
|
function lerp(a, b, t) { return a + (b - a) * t; }
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function isLightTheme() {
|
|
|
|
|
|
return currentTheme === 'light';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
|
return String(value === undefined || value === null ? '' : value)
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
function setTextById(id, text) {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (el) el.textContent = text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function colorMix(c1, c2, t) {
|
2026-04-16 15:03:02 +00:00
|
|
|
|
return {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
r: Math.round(lerp(c1.r, c2.r, t)),
|
|
|
|
|
|
g: Math.round(lerp(c1.g, c2.g, t)),
|
|
|
|
|
|
b: Math.round(lerp(c1.b, c2.b, t))
|
2026-04-16 15:03:02 +00:00
|
|
|
|
};
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 07:57:32 +00:00
|
|
|
|
function colorToCss(c, a) {
|
|
|
|
|
|
a = a === undefined ? 1 : a;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + a + ')';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function polar(cx, cy, r, a) {
|
|
|
|
|
|
return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
function valueToAngle(value) {
|
|
|
|
|
|
const ratio = clamp((Number(value) || 0) / GAUGE_MAX_PERCENT, 0, 1);
|
|
|
|
|
|
return START_ANGLE + ratio * (END_ANGLE - START_ANGLE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function prepCanvas(canvas) {
|
|
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const w = Math.max(1, Math.floor(rect.width));
|
|
|
|
|
|
const h = Math.max(1, Math.floor(rect.height));
|
2026-04-16 15:46:07 +00:00
|
|
|
|
canvas.width = Math.max(1, Math.floor(w * dpr));
|
|
|
|
|
|
canvas.height = Math.max(1, Math.floor(h * dpr));
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
|
|
return { ctx, w, h };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function gaugeBandColor(v) {
|
|
|
|
|
|
const green = { r: 34, g: 197, b: 94 };
|
|
|
|
|
|
const yellow = { r: 234, g: 179, b: 8 };
|
|
|
|
|
|
const red = { r: 239, g: 68, b: 68 };
|
|
|
|
|
|
|
|
|
|
|
|
if (v <= WARNING_PERCENT) {
|
|
|
|
|
|
const t = WARNING_PERCENT <= 0 ? 0 : v / WARNING_PERCENT;
|
|
|
|
|
|
return colorMix(green, yellow, t * 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (v <= CRITICAL_PERCENT) {
|
|
|
|
|
|
const span = Math.max(1, CRITICAL_PERCENT - WARNING_PERCENT);
|
|
|
|
|
|
const t = (v - WARNING_PERCENT) / span;
|
|
|
|
|
|
return colorMix(green, yellow, t);
|
|
|
|
|
|
}
|
|
|
|
|
|
const span = Math.max(1, GAUGE_MAX_PERCENT - CRITICAL_PERCENT);
|
|
|
|
|
|
const t = (v - CRITICAL_PERCENT) / span;
|
|
|
|
|
|
return colorMix(yellow, red, t);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 07:57:32 +00:00
|
|
|
|
function drawArc(ctx, cx, cy, r, a1, a2, stroke, width, shadowBlur) {
|
|
|
|
|
|
shadowBlur = shadowBlur || 0;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(cx, cy, r, a1, a2, false);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.strokeStyle = stroke;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.lineWidth = width;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.lineCap = 'butt';
|
|
|
|
|
|
if (shadowBlur > 0) {
|
|
|
|
|
|
ctx.shadowColor = stroke;
|
|
|
|
|
|
ctx.shadowBlur = shadowBlur;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function drawColoredBand(ctx, cx, cy, r, width) {
|
|
|
|
|
|
const segments = 180;
|
|
|
|
|
|
for (let i = 0; i < segments; i++) {
|
|
|
|
|
|
const v1 = (i / segments) * GAUGE_MAX_PERCENT;
|
|
|
|
|
|
const v2 = ((i + 1) / segments) * GAUGE_MAX_PERCENT;
|
|
|
|
|
|
const a1 = valueToAngle(v1);
|
|
|
|
|
|
const a2 = valueToAngle(v2);
|
|
|
|
|
|
const c = gaugeBandColor((v1 + v2) / 2);
|
|
|
|
|
|
drawArc(ctx, cx, cy, r, a1, a2, colorToCss(c, 0.95), width, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_GAUGES) return;
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const canvas = document.getElementById(canvasId);
|
|
|
|
|
|
if (!canvas) return;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
const prep = prepCanvas(canvas);
|
|
|
|
|
|
const ctx = prep.ctx;
|
|
|
|
|
|
const w = prep.w;
|
|
|
|
|
|
const h = prep.h;
|
|
|
|
|
|
const light = isLightTheme();
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
|
|
|
|
|
const cx = w / 2;
|
2026-04-19 07:22:16 +00:00
|
|
|
|
const radius = Math.min(w * 0.35, h * 0.42);
|
|
|
|
|
|
const cy = h * 0.58;
|
|
|
|
|
|
const trackWidth = Math.max(20, radius * 0.17);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
|
|
|
|
|
|
const valueAngle = valueToAngle(value);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
|
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.beginPath();
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? 'rgba(15,23,42,0.04)' : 'rgba(255,255,255,0.015)';
|
|
|
|
|
|
ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(0,0,0,0.45)';
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ctx.shadowBlur = 32;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, light ? 'rgba(15,23,42,0.08)' : 'rgba(255,255,255,0.06)', trackWidth + 10, 0);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
drawColoredBand(ctx, cx, cy, radius, trackWidth);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, light ? 'rgba(255,255,255,0.72)' : 'rgba(9,9,11,0.60)', trackWidth - 1, 0);
|
|
|
|
|
|
drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, light ? 'rgba(15,23,42,0.05)' : 'rgba(255,255,255,0.04)', trackWidth - 1, 10);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) {
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const a = valueToAngle(v);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
const isMajor = Math.abs(v % 10) < 0.0001;
|
2026-04-16 16:37:16 +00:00
|
|
|
|
const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80;
|
|
|
|
|
|
const r2 = radius * 0.97;
|
|
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const p1 = polar(cx, cy, r1, a);
|
|
|
|
|
|
const p2 = polar(cx, cy, r2, a);
|
|
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.moveTo(p1.x, p1.y);
|
|
|
|
|
|
ctx.lineTo(p2.x, p2.y);
|
2026-04-16 16:37:16 +00:00
|
|
|
|
|
|
|
|
|
|
if (isThreshold) {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.strokeStyle = light ? '#0f172a' : '#ffffff';
|
2026-04-16 17:02:59 +00:00
|
|
|
|
ctx.lineWidth = 3.2;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
} else if (isMajor) {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.strokeStyle = light ? 'rgba(15,23,42,0.80)' : 'rgba(255,255,255,0.86)';
|
2026-04-16 17:02:59 +00:00
|
|
|
|
ctx.lineWidth = 2.2;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
} else {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.strokeStyle = light ? 'rgba(71,85,105,0.65)' : 'rgba(161,161,170,0.74)';
|
2026-04-16 17:02:59 +00:00
|
|
|
|
ctx.lineWidth = 1.1;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
const labels = [0, 20, 40, 60, 80, 100, 120, 130];
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
|
ctx.textBaseline = 'middle';
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? 'rgba(15,23,42,0.88)' : 'rgba(244,244,245,0.96)';
|
2026-04-17 07:57:32 +00:00
|
|
|
|
ctx.font = '700 18px system-ui, sans-serif';
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
for (const v of labels) {
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const a = valueToAngle(v);
|
2026-04-16 17:02:59 +00:00
|
|
|
|
const p = polar(cx, cy, radius * 1.13, a);
|
|
|
|
|
|
ctx.fillText(String(v), p.x, p.y);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:02:59 +00:00
|
|
|
|
const tip = polar(cx, cy, radius * 0.86, valueAngle);
|
|
|
|
|
|
const left = polar(cx, cy, 8, valueAngle + Math.PI / 2);
|
|
|
|
|
|
const right = polar(cx, cy, 8, valueAngle - Math.PI / 2);
|
|
|
|
|
|
const tail = polar(cx, cy, radius * 0.20, valueAngle + Math.PI);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
|
|
|
|
|
ctx.save();
|
|
|
|
|
|
ctx.beginPath();
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.moveTo(left.x, left.y);
|
|
|
|
|
|
ctx.lineTo(tip.x, tip.y);
|
|
|
|
|
|
ctx.lineTo(right.x, right.y);
|
|
|
|
|
|
ctx.lineTo(tail.x, tail.y);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.closePath();
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
|
|
|
|
|
ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.18)';
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.shadowBlur = 10;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
2026-04-16 17:02:59 +00:00
|
|
|
|
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? '#ffffff' : '#101114';
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.fill();
|
|
|
|
|
|
ctx.lineWidth = 3;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.strokeStyle = sideAccent;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.fill();
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const majorTickInner = radius * 0.72;
|
|
|
|
|
|
const centerPlateRadius = majorTickInner - 18;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.beginPath();
|
2026-04-16 17:27:50 +00:00
|
|
|
|
ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.90)';
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.fill();
|
2026-04-16 17:27:50 +00:00
|
|
|
|
ctx.lineWidth = 1.2;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.strokeStyle = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.10)';
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.stroke();
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const valueText = value.toFixed(1);
|
2026-04-19 07:22:16 +00:00
|
|
|
|
let valueFontPx = 58;
|
|
|
|
|
|
if (value >= 100) valueFontPx = 50;
|
|
|
|
|
|
if (w < 420) valueFontPx -= 6;
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
ctx.textAlign = 'center';
|
|
|
|
|
|
ctx.textBaseline = 'middle';
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
|
2026-04-17 07:57:32 +00:00
|
|
|
|
ctx.font = '700 ' + valueFontPx + 'px system-ui, sans-serif';
|
2026-04-16 17:27:50 +00:00
|
|
|
|
ctx.fillText(valueText, cx, cy - 6);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
ctx.fillStyle = sideAccent;
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ctx.font = '700 19px system-ui, sans-serif';
|
|
|
|
|
|
ctx.fillText(UNIT_PCT, cx, cy + 30);
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
ctx.fillStyle = light ? '#334155' : '#a1a1aa';
|
2026-04-19 07:22:16 +00:00
|
|
|
|
ctx.font = '600 17px system-ui, sans-serif';
|
|
|
|
|
|
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 58);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getZone(percentValue) {
|
|
|
|
|
|
if (percentValue >= CRITICAL_PERCENT) return 'critical';
|
|
|
|
|
|
if (percentValue >= WARNING_PERCENT) return 'warning';
|
2026-04-16 17:27:50 +00:00
|
|
|
|
return 'ok';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getImbalanceZone(value) {
|
|
|
|
|
|
if (value >= IMBALANCE_CRITICAL_PERCENT) return 'critical';
|
|
|
|
|
|
if (value >= IMBALANCE_WARNING_PERCENT) return 'warning';
|
|
|
|
|
|
return 'ok';
|
2026-04-16 15:46:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 17:52:51 +00:00
|
|
|
|
function setProcessVisualState(connected) {
|
|
|
|
|
|
const el = document.getElementById('process-content');
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (connected) {
|
|
|
|
|
|
el.classList.remove('process-offline');
|
|
|
|
|
|
el.classList.add('process-online');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
el.classList.remove('process-online');
|
|
|
|
|
|
el.classList.add('process-offline');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function setConnectionIndicator(connected, stale) {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const dot = document.getElementById('dot');
|
|
|
|
|
|
const text = document.getElementById('status-text');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!dot || !text) return;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if (!connected) {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
|
|
|
|
|
|
text.textContent = 'Disconnected';
|
|
|
|
|
|
text.className = 'font-semibold text-lg text-red-400';
|
2026-04-17 15:40:47 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (stale) {
|
|
|
|
|
|
dot.className = 'w-4 h-4 rounded-full bg-yellow-400 ring-4 ring-yellow-400/20';
|
|
|
|
|
|
text.textContent = 'Stale';
|
|
|
|
|
|
text.className = 'font-semibold text-lg text-yellow-300';
|
|
|
|
|
|
return;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20';
|
|
|
|
|
|
text.textContent = 'Connected';
|
|
|
|
|
|
text.className = 'font-semibold text-lg text-emerald-400';
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyChannelState(side, percentValue) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_GAUGES) return;
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const zone = getZone(percentValue);
|
|
|
|
|
|
const card = document.getElementById('card-' + side);
|
|
|
|
|
|
const led = document.getElementById('led-' + side);
|
|
|
|
|
|
const stateText = document.getElementById('state-' + side);
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!card || !led || !stateText) return;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
|
|
|
|
|
|
|
|
|
|
|
|
if (zone === 'critical') {
|
|
|
|
|
|
led.className = 'w-6 h-6 bg-red-500 rounded-full shadow-lg shadow-red-600/50';
|
|
|
|
|
|
stateText.textContent = 'CRITICAL';
|
|
|
|
|
|
stateText.className = 'text-sm text-red-400 mt-1 font-semibold';
|
|
|
|
|
|
card.classList.add('soft-glow-red');
|
|
|
|
|
|
} else if (zone === 'warning') {
|
|
|
|
|
|
led.className = 'w-6 h-6 bg-yellow-400 rounded-full shadow-lg shadow-yellow-500/50';
|
|
|
|
|
|
stateText.textContent = 'WARNING';
|
|
|
|
|
|
stateText.className = 'text-sm text-yellow-400 mt-1 font-semibold';
|
|
|
|
|
|
card.classList.add('soft-glow-yellow');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
led.className = 'w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40';
|
|
|
|
|
|
stateText.textContent = 'NORMAL';
|
|
|
|
|
|
stateText.className = 'text-sm text-emerald-400 mt-1 font-semibold';
|
|
|
|
|
|
card.classList.add('soft-glow-green');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatLastUpdate(isoString) {
|
|
|
|
|
|
if (!isoString) return 'Last update: --:--:--.---';
|
|
|
|
|
|
const d = new Date(isoString);
|
|
|
|
|
|
if (isNaN(d.getTime())) return 'Last update: --:--:--.---';
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
|
|
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
|
|
|
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
|
|
|
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
function setSummaryCard(kind, zone, text, value) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_SUMMARY_BAR) return;
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const card = document.getElementById('summary-' + kind + '-card');
|
|
|
|
|
|
const dot = document.getElementById('summary-' + kind + '-dot');
|
|
|
|
|
|
const status = document.getElementById('summary-' + kind + '-text');
|
|
|
|
|
|
const val = document.getElementById('summary-' + kind + '-value');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!card || !dot || !status || !val) return;
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
|
|
|
|
|
card.className = 'summary-card ' + zone;
|
|
|
|
|
|
dot.className = 'summary-dot ' + zone;
|
|
|
|
|
|
status.className = 'summary-status ' + zone + ' font-semibold mt-1';
|
|
|
|
|
|
status.textContent = text;
|
|
|
|
|
|
val.textContent = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function setVerdict(zone, statusText, reasonText) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_VERDICT) return;
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
const card = document.getElementById('verdict-card');
|
|
|
|
|
|
const status = document.getElementById('verdict-status');
|
|
|
|
|
|
const reason = document.getElementById('verdict-reason');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!card || !status || !reason) return;
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
card.className = 'verdict-card ' + zone;
|
|
|
|
|
|
status.textContent = statusText;
|
|
|
|
|
|
reason.textContent = reasonText;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_SUMMARY_BAR) return;
|
|
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
if (!connected) {
|
|
|
|
|
|
setSummaryCard('force', 'neutral', 'NO DATA', '--');
|
|
|
|
|
|
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
|
|
|
|
|
|
setSummaryCard('plc', 'critical', 'OFFLINE', 'Disconnected');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const maxForce = Math.max(leftPercent, rightPercent);
|
|
|
|
|
|
const forceZone = getZone(maxForce);
|
|
|
|
|
|
const dominantSide = leftPercent >= rightPercent ? 'L' : 'R';
|
|
|
|
|
|
const forceText = forceZone === 'ok' ? 'OK' : forceZone === 'warning' ? 'WARNING' : 'CRITICAL';
|
|
|
|
|
|
setSummaryCard('force', forceZone, forceText, 'Max ' + maxForce.toFixed(1) + UNIT_PCT + ' (' + dominantSide + ')');
|
|
|
|
|
|
|
|
|
|
|
|
const imbalanceZone = getImbalanceZone(imbalance);
|
|
|
|
|
|
const imbalanceText = imbalanceZone === 'ok' ? 'OK' : imbalanceZone === 'warning' ? 'WARNING' : 'CRITICAL';
|
|
|
|
|
|
setSummaryCard('imbalance', imbalanceZone, imbalanceText, imbalance.toFixed(1) + UNIT_PCT);
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if (stale) {
|
|
|
|
|
|
setSummaryCard('plc', 'warning', 'STALE', 'No fresh data');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSummaryCard('plc', 'ok', 'OK', 'Online');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_VERDICT) return;
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if (!connected) {
|
|
|
|
|
|
setVerdict('critical', 'OFFLINE', 'No PLC communication');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (stale) {
|
|
|
|
|
|
setVerdict('warning', 'STALE DATA', 'PLC connected but no fresh values received');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const leftCritical = leftPercent >= CRITICAL_PERCENT;
|
|
|
|
|
|
const rightCritical = rightPercent >= CRITICAL_PERCENT;
|
|
|
|
|
|
const imbCritical = imbalance >= IMBALANCE_CRITICAL_PERCENT;
|
|
|
|
|
|
|
|
|
|
|
|
if (leftCritical || rightCritical || imbCritical) {
|
|
|
|
|
|
const reasons = [];
|
|
|
|
|
|
if (leftCritical) reasons.push('left force critical');
|
|
|
|
|
|
if (rightCritical) reasons.push('right force critical');
|
|
|
|
|
|
if (imbCritical) reasons.push('imbalance critical');
|
|
|
|
|
|
setVerdict('critical', 'CRITICAL', reasons.join(' • '));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const leftWarning = leftPercent >= WARNING_PERCENT;
|
|
|
|
|
|
const rightWarning = rightPercent >= WARNING_PERCENT;
|
|
|
|
|
|
const imbWarning = imbalance >= IMBALANCE_WARNING_PERCENT;
|
|
|
|
|
|
|
|
|
|
|
|
if (leftWarning || rightWarning || imbWarning) {
|
|
|
|
|
|
const reasons = [];
|
|
|
|
|
|
if (leftWarning) reasons.push('left force warning');
|
|
|
|
|
|
if (rightWarning) reasons.push('right force warning');
|
|
|
|
|
|
if (imbWarning) reasons.push('imbalance warning');
|
|
|
|
|
|
setVerdict('warning', 'WARNING', reasons.join(' • '));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setVerdict('ok', 'OK', 'Production stable within configured force and imbalance limits');
|
2026-04-16 17:27:50 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const banner = document.getElementById('alarm-banner');
|
|
|
|
|
|
const text = document.getElementById('alarm-text');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!banner || !text) return;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
if (!connected) {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE';
|
|
|
|
|
|
banner.classList.remove('hidden');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (stale) {
|
|
|
|
|
|
text.textContent = 'WARNING • PLC DATA STALE';
|
|
|
|
|
|
banner.classList.remove('hidden');
|
2026-04-16 17:27:50 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const leftCritical = leftPercent >= CRITICAL_PERCENT;
|
|
|
|
|
|
const rightCritical = rightPercent >= CRITICAL_PERCENT;
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const imbalanceCritical = imbalancePercent >= IMBALANCE_CRITICAL_PERCENT;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
if (!leftCritical && !rightCritical && !imbalanceCritical) {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
banner.classList.add('hidden');
|
2026-04-16 17:27:50 +00:00
|
|
|
|
return;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-16 17:27:50 +00:00
|
|
|
|
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
if (leftCritical && rightCritical) {
|
|
|
|
|
|
parts.push('FORCE LEFT + RIGHT');
|
|
|
|
|
|
} else if (leftCritical) {
|
|
|
|
|
|
parts.push('FORCE LEFT');
|
|
|
|
|
|
} else if (rightCritical) {
|
|
|
|
|
|
parts.push('FORCE RIGHT');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (imbalanceCritical) {
|
|
|
|
|
|
parts.push('IMBALANCE');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • ');
|
2026-04-16 17:27:50 +00:00
|
|
|
|
banner.classList.remove('hidden');
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function redrawGauges() {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_GAUGES) return;
|
2026-04-16 15:03:02 +00:00
|
|
|
|
if (!latestData) return;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:03:02 +00:00
|
|
|
|
const leftPercent = Number(latestData.sila_l) || 0;
|
|
|
|
|
|
const rightPercent = Number(latestData.sila_r) || 0;
|
|
|
|
|
|
const leftKN = Number(latestData.sila_l_kn) || 0;
|
|
|
|
|
|
const rightKN = Number(latestData.sila_r_kn) || 0;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
|
|
|
|
|
drawGauge('gaugeL', leftPercent, leftKN, '#22d3ee');
|
|
|
|
|
|
drawGauge('gaugeR', rightPercent, rightKN, '#c084fc');
|
2026-04-16 15:03:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function directionLabel(direction) {
|
|
|
|
|
|
if (direction === 'rising') return '↑ rising';
|
|
|
|
|
|
if (direction === 'falling') return '↓ falling';
|
|
|
|
|
|
if (direction === 'worsening') return '↑ worsening';
|
|
|
|
|
|
if (direction === 'improving') return '↓ improving';
|
|
|
|
|
|
if (direction === 'stable') return '→ stable';
|
|
|
|
|
|
return 'No data';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function directionClass(direction) {
|
|
|
|
|
|
if (direction === 'rising') return 'dir-up';
|
|
|
|
|
|
if (direction === 'falling') return 'dir-down';
|
|
|
|
|
|
if (direction === 'worsening') return 'dir-bad';
|
|
|
|
|
|
if (direction === 'improving') return 'dir-down';
|
|
|
|
|
|
if (direction === 'stable') return 'dir-flat';
|
|
|
|
|
|
return 'dir-flat';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trendZoneForForce(direction, delta) {
|
|
|
|
|
|
const abs = Math.abs(delta);
|
|
|
|
|
|
if (direction === 'insufficient_data') return 'neutral';
|
|
|
|
|
|
if (direction === 'stable') return 'ok';
|
|
|
|
|
|
if (direction === 'rising') return abs >= 8 ? 'critical' : 'warning';
|
|
|
|
|
|
return 'ok';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trendZoneForImbalance(direction, delta) {
|
|
|
|
|
|
const abs = Math.abs(delta);
|
|
|
|
|
|
if (direction === 'insufficient_data') return 'neutral';
|
|
|
|
|
|
if (direction === 'stable') return 'ok';
|
|
|
|
|
|
if (direction === 'worsening') return abs >= 4 ? 'critical' : 'warning';
|
|
|
|
|
|
return 'ok';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stabilityZone(stability) {
|
|
|
|
|
|
if (stability === 'stable') return 'ok';
|
|
|
|
|
|
if (stability === 'caution') return 'warning';
|
|
|
|
|
|
if (stability === 'unstable') return 'critical';
|
|
|
|
|
|
return 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setIntelCard(idPrefix, zone, valueText, subText) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_INTELLIGENCE) return;
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
const card = document.getElementById(idPrefix + '-card');
|
|
|
|
|
|
const value = document.getElementById(idPrefix + '-value');
|
|
|
|
|
|
const sub = document.getElementById(idPrefix + '-sub');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!card || !value || !sub) return;
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
card.className = 'intel-card ' + zone;
|
|
|
|
|
|
value.innerHTML = valueText;
|
|
|
|
|
|
sub.innerHTML = subText;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatSource(source) {
|
|
|
|
|
|
if (source === 'force_left') return 'LEFT';
|
|
|
|
|
|
if (source === 'force_right') return 'RIGHT';
|
|
|
|
|
|
if (source === 'imbalance') return 'IMBALANCE';
|
|
|
|
|
|
if (source === 'plc') return 'PLC';
|
|
|
|
|
|
return String(source || '').toUpperCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatValue(value) {
|
|
|
|
|
|
const n = Number(value);
|
|
|
|
|
|
if (!isFinite(n)) return '--';
|
|
|
|
|
|
return n.toFixed(1) + UNIT_PCT;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyTheme(theme) {
|
|
|
|
|
|
currentTheme = theme === 'light' ? 'light' : 'dark';
|
|
|
|
|
|
document.body.setAttribute('data-theme', currentTheme);
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem('force-monitor-theme', currentTheme);
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
updateThemeButton();
|
|
|
|
|
|
updateChartTheme();
|
|
|
|
|
|
redrawGauges();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function initTheme() {
|
|
|
|
|
|
let theme = 'dark';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stored = localStorage.getItem('force-monitor-theme');
|
|
|
|
|
|
if (stored === 'light' || stored === 'dark') {
|
|
|
|
|
|
theme = stored;
|
|
|
|
|
|
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
|
|
|
|
theme = 'light';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
applyTheme(theme);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleTheme() {
|
|
|
|
|
|
applyTheme(isLightTheme() ? 'dark' : 'light');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateThemeButton() {
|
|
|
|
|
|
const btn = document.getElementById('theme-toggle');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateFullscreenButton() {
|
|
|
|
|
|
const btn = document.getElementById('fullscreen-toggle');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleFullscreen() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!document.fullscreenElement) {
|
|
|
|
|
|
await document.documentElement.requestFullscreen();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await document.exitFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('Fullscreen error:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
updateFullscreenButton();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateChartTheme() {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_TREND_CHART || !lineChart) return;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
const light = isLightTheme();
|
|
|
|
|
|
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
|
|
|
|
|
|
const tick = light ? '#334155' : '#a1a1aa';
|
|
|
|
|
|
const legend = light ? '#0f172a' : '#f4f4f5';
|
|
|
|
|
|
const tooltipBg = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.96)';
|
|
|
|
|
|
const tooltipText = light ? '#0f172a' : '#f4f4f5';
|
|
|
|
|
|
|
|
|
|
|
|
lineChart.options.scales.x.grid.color = grid;
|
|
|
|
|
|
lineChart.options.scales.x.ticks.color = tick;
|
|
|
|
|
|
lineChart.options.scales.y.grid.color = grid;
|
|
|
|
|
|
lineChart.options.scales.y.ticks.color = tick;
|
|
|
|
|
|
lineChart.options.plugins.legend.labels.color = legend;
|
|
|
|
|
|
lineChart.options.plugins.tooltip.backgroundColor = tooltipBg;
|
|
|
|
|
|
lineChart.options.plugins.tooltip.titleColor = tooltipText;
|
|
|
|
|
|
lineChart.options.plugins.tooltip.bodyColor = tooltipText;
|
|
|
|
|
|
lineChart.update('none');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
async function fetchLiveData() {
|
2026-04-16 11:51:18 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/data', { cache: 'no-store' });
|
|
|
|
|
|
const d = await res.json();
|
2026-04-16 15:03:02 +00:00
|
|
|
|
latestData = d;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-16 17:27:50 +00:00
|
|
|
|
const connected = !!d.connected;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
const leftPercent = Number(d.sila_l) || 0;
|
|
|
|
|
|
const rightPercent = Number(d.sila_r) || 0;
|
|
|
|
|
|
const sumPercent = Number(d.sum_percent) || 0;
|
|
|
|
|
|
const sumKN = Number(d.sum_kn) || 0;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
const imbalance = Number(d.imbalance_percent) || 0;
|
|
|
|
|
|
const bias = Number(d.bias_percent) || 0;
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
let stale = false;
|
|
|
|
|
|
if (connected && d.last_update) {
|
|
|
|
|
|
const lastTs = new Date(d.last_update).getTime();
|
|
|
|
|
|
if (!isNaN(lastTs)) {
|
|
|
|
|
|
stale = (Date.now() - lastTs) > STALE_MS;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setConnectionIndicator(connected, stale);
|
2026-04-17 17:52:51 +00:00
|
|
|
|
setProcessVisualState(connected && !stale);
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (SHOW_OVERVIEW) {
|
|
|
|
|
|
setTextById('sum-percent', sumPercent.toFixed(1));
|
|
|
|
|
|
setTextById('sum-kn', sumKN.toFixed(1));
|
|
|
|
|
|
setTextById('imbalance-pct', imbalance.toFixed(1));
|
|
|
|
|
|
setTextById('bias-pct', bias.toFixed(1));
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
setTextById('last-update', formatLastUpdate(d.last_update));
|
|
|
|
|
|
setTextById('dropped-samples', String(d.dropped_samples || 0));
|
|
|
|
|
|
setTextById('dropped-events', String(d.dropped_events || 0));
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
|
|
|
|
|
applyChannelState('l', leftPercent);
|
|
|
|
|
|
applyChannelState('r', rightPercent);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance);
|
|
|
|
|
|
updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance);
|
|
|
|
|
|
updateAlarmBanner(leftPercent, rightPercent, imbalance, connected, stale);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
redrawGauges();
|
2026-04-16 11:51:18 +00:00
|
|
|
|
} catch (err) {
|
2026-04-16 15:46:07 +00:00
|
|
|
|
console.warn('Live fetch error:', err);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
setConnectionIndicator(false, false);
|
2026-04-17 17:52:51 +00:00
|
|
|
|
setProcessVisualState(false);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
updateSummaryBar(false, false, 0, 0, 0);
|
|
|
|
|
|
updateMachineVerdict(false, false, 0, 0, 0);
|
|
|
|
|
|
updateAlarmBanner(0, 0, 0, false, false);
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
async function fetchHistory() {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_TREND_CHART || !lineChart) return;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
if (historyBusy) return;
|
|
|
|
|
|
historyBusy = true;
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' });
|
|
|
|
|
|
if (!res.ok) throw new Error('History request failed');
|
2026-04-16 17:02:59 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
const d = await res.json();
|
|
|
|
|
|
const pts = Array.isArray(d.points) ? d.points : [];
|
|
|
|
|
|
const labels = pts.map(p => p.time);
|
|
|
|
|
|
const dataL = pts.map(p => p.sila_l);
|
|
|
|
|
|
const dataR = pts.map(p => p.sila_r);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
const warnLine = labels.map(() => WARNING_PERCENT);
|
|
|
|
|
|
const critLine = labels.map(() => CRITICAL_PERCENT);
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
|
|
|
|
|
lineChart.data.labels = labels;
|
|
|
|
|
|
lineChart.data.datasets[0].data = dataL;
|
|
|
|
|
|
lineChart.data.datasets[1].data = dataR;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
lineChart.data.datasets[2].data = warnLine;
|
|
|
|
|
|
lineChart.data.datasets[3].data = critLine;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
lineChart.update('none');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('History fetch error:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
historyBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
async function fetchTrend() {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_INTELLIGENCE) return;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if (trendBusy) return;
|
|
|
|
|
|
trendBusy = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/trend?window=' + encodeURIComponent(currentTrendWindow), { cache: 'no-store' });
|
|
|
|
|
|
if (!res.ok) throw new Error('Trend request failed');
|
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
const avgPeak5m = Number(d.avg_peak_5m) || 0;
|
|
|
|
|
|
const avgPeak1h = Number(d.avg_peak_1h) || 0;
|
|
|
|
|
|
const avgImb5m = Number(d.avg_imbalance_5m) || 0;
|
|
|
|
|
|
const avgImb1h = Number(d.avg_imbalance_1h) || 0;
|
|
|
|
|
|
const forceDelta = Number(d.force_delta_pct) || 0;
|
|
|
|
|
|
const imbDelta = Number(d.imbalance_delta_pct) || 0;
|
|
|
|
|
|
const forceDir = d.force_direction || 'insufficient_data';
|
|
|
|
|
|
const imbDir = d.imbalance_direction || 'insufficient_data';
|
|
|
|
|
|
const stability = d.process_stability || 'insufficient_data';
|
|
|
|
|
|
const stabilityReason = d.stability_reason || 'No data';
|
|
|
|
|
|
const forceStd = Number(d.force_stddev) || 0;
|
|
|
|
|
|
const imbStd = Number(d.imbalance_stddev) || 0;
|
|
|
|
|
|
const sampleCount = Number(d.sample_count) || 0;
|
|
|
|
|
|
const windowLabel = d.window || currentTrendWindow;
|
|
|
|
|
|
|
|
|
|
|
|
setIntelCard(
|
|
|
|
|
|
'intel-avg5',
|
|
|
|
|
|
getZone(avgPeak5m),
|
|
|
|
|
|
avgPeak5m.toFixed(1) + UNIT_PCT,
|
|
|
|
|
|
'Avg imbalance 5m: <span class="intel-kpi">' + avgImb5m.toFixed(1) + UNIT_PCT + '</span>'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setIntelCard(
|
|
|
|
|
|
'intel-avg1h',
|
|
|
|
|
|
getZone(avgPeak1h),
|
|
|
|
|
|
avgPeak1h.toFixed(1) + UNIT_PCT,
|
|
|
|
|
|
'Avg imbalance 1h: <span class="intel-kpi">' + avgImb1h.toFixed(1) + UNIT_PCT + '</span>'
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setIntelCard(
|
|
|
|
|
|
'intel-force',
|
|
|
|
|
|
trendZoneForForce(forceDir, forceDelta),
|
|
|
|
|
|
(forceDelta >= 0 ? '+' : '') + forceDelta.toFixed(1) + UNIT_PCT,
|
|
|
|
|
|
'<span class="' + directionClass(forceDir) + ' font-semibold">' + directionLabel(forceDir) + '</span><br>Δ avg force over ' + windowLabel + ' • σ ' + forceStd.toFixed(2)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setIntelCard(
|
|
|
|
|
|
'intel-imb',
|
|
|
|
|
|
trendZoneForImbalance(imbDir, imbDelta),
|
|
|
|
|
|
(imbDelta >= 0 ? '+' : '') + imbDelta.toFixed(1) + UNIT_PCT,
|
|
|
|
|
|
'<span class="' + directionClass(imbDir) + ' font-semibold">' + directionLabel(imbDir) + '</span><br>Δ avg imbalance over ' + windowLabel + ' • σ ' + imbStd.toFixed(2)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setIntelCard(
|
|
|
|
|
|
'intel-stability',
|
|
|
|
|
|
stabilityZone(stability),
|
|
|
|
|
|
String(stability).toUpperCase(),
|
|
|
|
|
|
stabilityReason + '<br><span class="mini-mono">Samples: ' + sampleCount + ' • Window: ' + windowLabel + '</span>'
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('Trend fetch error:', err);
|
|
|
|
|
|
setIntelCard('intel-avg5', 'neutral', '--', 'No data');
|
|
|
|
|
|
setIntelCard('intel-avg1h', 'neutral', '--', 'No data');
|
|
|
|
|
|
setIntelCard('intel-force', 'neutral', '--', 'No data');
|
|
|
|
|
|
setIntelCard('intel-imb', 'neutral', '--', 'No data');
|
|
|
|
|
|
setIntelCard('intel-stability', 'neutral', '--', 'No data');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
trendBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchAlarms() {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_ALARM_TIMELINE) return;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
if (alarmsBusy) return;
|
|
|
|
|
|
alarmsBusy = true;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/alarms?limit=20', { cache: 'no-store' });
|
|
|
|
|
|
if (!res.ok) throw new Error('Alarm request failed');
|
|
|
|
|
|
const d = await res.json();
|
|
|
|
|
|
const events = Array.isArray(d.events) ? d.events : [];
|
|
|
|
|
|
const body = document.getElementById('alarm-table-body');
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!body) return;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
if (events.length === 0) {
|
|
|
|
|
|
body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let html = '';
|
|
|
|
|
|
for (let i = 0; i < events.length; i++) {
|
|
|
|
|
|
const ev = events[i];
|
|
|
|
|
|
const sev = String(ev.severity || 'info');
|
|
|
|
|
|
const val = ev.source === 'plc' ? '--' : formatValue(ev.value);
|
|
|
|
|
|
const lim = ev.limit > 0 ? formatValue(ev.limit) : '--';
|
|
|
|
|
|
|
|
|
|
|
|
html += '<tr class="border-b border-white/5">';
|
|
|
|
|
|
html += '<td class="py-3 pr-4 font-mono text-zinc-300 whitespace-nowrap">' + escapeHtml(ev.time || '--') + '</td>';
|
|
|
|
|
|
html += '<td class="py-3 pr-4"><span class="severity-pill ' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></td>';
|
|
|
|
|
|
html += '<td class="py-3 pr-4 font-semibold text-zinc-200">' + escapeHtml(formatSource(ev.source)) + '</td>';
|
|
|
|
|
|
html += '<td class="py-3 pr-4 text-zinc-300">' + escapeHtml(ev.message || '--') + '</td>';
|
|
|
|
|
|
html += '<td class="py-3 pr-4 text-right font-mono text-zinc-200">' + escapeHtml(val) + '</td>';
|
|
|
|
|
|
html += '<td class="py-3 text-right font-mono text-zinc-400">' + escapeHtml(lim) + '</td>';
|
|
|
|
|
|
html += '</tr>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body.innerHTML = html;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('Alarm fetch error:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
alarmsBusy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function setActiveWindowButton(value) {
|
|
|
|
|
|
document.querySelectorAll('.window-btn').forEach(btn => {
|
|
|
|
|
|
btn.classList.toggle('active', btn.dataset.window === value);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function setActiveTrendWindowButton(value) {
|
|
|
|
|
|
document.querySelectorAll('.trend-window-btn').forEach(btn => {
|
|
|
|
|
|
btn.classList.toggle('active', btn.dataset.window === value);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
function useWindow(value) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_TREND_CHART) return;
|
2026-04-16 15:46:07 +00:00
|
|
|
|
currentWindow = value;
|
|
|
|
|
|
setActiveWindowButton(value);
|
|
|
|
|
|
fetchHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
function useTrendWindow(value) {
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (!SHOW_INTELLIGENCE) return;
|
2026-04-17 15:40:47 +00:00
|
|
|
|
currentTrendWindow = value;
|
|
|
|
|
|
setActiveTrendWindowButton(value);
|
|
|
|
|
|
fetchTrend();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 11:51:18 +00:00
|
|
|
|
window.onload = () => {
|
2026-04-17 15:40:47 +00:00
|
|
|
|
initTheme();
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
setActiveWindowButton(DEFAULT_WINDOW);
|
2026-04-17 15:40:47 +00:00
|
|
|
|
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (SHOW_HEADER_CONTROLS) {
|
|
|
|
|
|
const themeBtn = document.getElementById('theme-toggle');
|
|
|
|
|
|
const fsBtn = document.getElementById('fullscreen-toggle');
|
|
|
|
|
|
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
|
|
|
|
|
|
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
|
|
|
|
|
|
}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.window-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', () => useWindow(btn.dataset.window));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-17 15:40:47 +00:00
|
|
|
|
document.querySelectorAll('.trend-window-btn').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
const applyWindowBtn = document.getElementById('apply-window');
|
|
|
|
|
|
if (applyWindowBtn) {
|
|
|
|
|
|
applyWindowBtn.addEventListener('click', () => {
|
|
|
|
|
|
const input = document.getElementById('custom-window');
|
|
|
|
|
|
const val = input ? input.value.trim() : '';
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
currentWindow = val;
|
|
|
|
|
|
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
|
|
|
|
|
|
fetchHistory();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
const applyTrendBtn = document.getElementById('apply-trend-window');
|
|
|
|
|
|
if (applyTrendBtn) {
|
|
|
|
|
|
applyTrendBtn.addEventListener('click', () => {
|
|
|
|
|
|
const input = document.getElementById('custom-trend-window');
|
|
|
|
|
|
const val = input ? input.value.trim() : '';
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
currentTrendWindow = val;
|
|
|
|
|
|
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
|
|
|
|
|
|
fetchTrend();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
|
|
|
|
|
updateFullscreenButton();
|
|
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
if (SHOW_TREND_CHART) {
|
|
|
|
|
|
const chartCanvas = document.getElementById('lineChart');
|
|
|
|
|
|
if (chartCanvas) {
|
|
|
|
|
|
lineChart = new Chart(chartCanvas, {
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
labels: [],
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Levi peak %',
|
|
|
|
|
|
borderColor: '#22d3ee',
|
|
|
|
|
|
backgroundColor: 'rgba(34,211,238,0.10)',
|
|
|
|
|
|
borderWidth: 3,
|
|
|
|
|
|
tension: 0.22,
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
data: []
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Desni peak %',
|
|
|
|
|
|
borderColor: '#c084fc',
|
|
|
|
|
|
backgroundColor: 'rgba(192,132,252,0.10)',
|
|
|
|
|
|
borderWidth: 3,
|
|
|
|
|
|
tension: 0.22,
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
data: []
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Warning limit',
|
|
|
|
|
|
borderColor: 'rgba(245,158,11,0.95)',
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
borderDash: [8, 6],
|
|
|
|
|
|
tension: 0,
|
|
|
|
|
|
data: []
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Critical limit',
|
|
|
|
|
|
borderColor: 'rgba(239,68,68,0.95)',
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
borderDash: [8, 6],
|
|
|
|
|
|
tension: 0,
|
|
|
|
|
|
data: []
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-04-16 11:51:18 +00:00
|
|
|
|
},
|
2026-04-17 17:32:33 +00:00
|
|
|
|
options: {
|
|
|
|
|
|
responsive: true,
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
interaction: { mode: 'index', intersect: false },
|
|
|
|
|
|
animation: false,
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
|
|
|
|
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
|
|
|
|
ticks: { color: '#a1a1aa', maxTicksLimit: 18 }
|
|
|
|
|
|
},
|
|
|
|
|
|
y: {
|
|
|
|
|
|
min: 0,
|
|
|
|
|
|
max: GAUGE_MAX_PERCENT,
|
|
|
|
|
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
|
|
|
|
ticks: { color: '#a1a1aa', stepSize: 10 }
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: { position: 'top', labels: { color: '#f4f4f5' } },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
backgroundColor: 'rgba(9,9,11,0.96)',
|
|
|
|
|
|
titleColor: '#f4f4f5',
|
|
|
|
|
|
bodyColor: '#f4f4f5'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-16 11:51:18 +00:00
|
|
|
|
}
|
2026-04-17 17:32:33 +00:00
|
|
|
|
});
|
2026-04-16 11:51:18 +00:00
|
|
|
|
|
2026-04-17 17:32:33 +00:00
|
|
|
|
updateChartTheme();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-17 15:40:47 +00:00
|
|
|
|
|
2026-04-17 17:52:51 +00:00
|
|
|
|
setProcessVisualState(false);
|
|
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
fetchLiveData();
|
|
|
|
|
|
fetchHistory();
|
2026-04-17 15:40:47 +00:00
|
|
|
|
fetchTrend();
|
|
|
|
|
|
fetchAlarms();
|
2026-04-16 15:03:02 +00:00
|
|
|
|
|
2026-04-16 15:46:07 +00:00
|
|
|
|
setInterval(fetchLiveData, POLL_MS);
|
2026-04-17 17:32:33 +00:00
|
|
|
|
|
|
|
|
|
|
if (SHOW_TREND_CHART) {
|
|
|
|
|
|
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (SHOW_INTELLIGENCE) {
|
|
|
|
|
|
setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (SHOW_ALARM_TIMELINE) {
|
|
|
|
|
|
setInterval(fetchAlarms, 2500);
|
|
|
|
|
|
}
|
2026-04-16 15:46:07 +00:00
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', redrawGauges);
|
2026-04-16 11:51:18 +00:00
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`
|