added modules; user can disable, enable them
This commit is contained in:
parent
8cd6e066e8
commit
b1d69f1697
408
main.go
408
main.go
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -29,7 +30,7 @@ import (
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
const version = "0.7.1"
|
const version = "0.8.0"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config structs
|
// Config structs
|
||||||
|
|
@ -42,6 +43,7 @@ type Config struct {
|
||||||
Trend TrendConfig `yaml:"trend"`
|
Trend TrendConfig `yaml:"trend"`
|
||||||
Press PressConfig `yaml:"press"`
|
Press PressConfig `yaml:"press"`
|
||||||
UI UIConfig `yaml:"ui"`
|
UI UIConfig `yaml:"ui"`
|
||||||
|
Modules ModulesConfig `yaml:"modules"`
|
||||||
DB DBConfig `yaml:"db"`
|
DB DBConfig `yaml:"db"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,9 +78,6 @@ type TrendConfig struct {
|
||||||
Minutes int `yaml:"minutes"`
|
Minutes int `yaml:"minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PressConfig: Go field is MaxTonnage (idiomatic). YAML tag kept as MAX_TONNAGE
|
|
||||||
// so existing config files need no changes. LegacyMaxTonnage handles old
|
|
||||||
// configs that used the lowercase "max_tonnage" key.
|
|
||||||
type PressConfig struct {
|
type PressConfig struct {
|
||||||
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
|
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
|
||||||
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
||||||
|
|
@ -93,6 +92,17 @@ type UIConfig struct {
|
||||||
UnitPct string `yaml:"unit_percent"`
|
UnitPct string `yaml:"unit_percent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
ShowTrendChart *bool `yaml:"show_trend_chart,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
BusyTimeoutMs int `yaml:"busy_timeout_ms"`
|
BusyTimeoutMs int `yaml:"busy_timeout_ms"`
|
||||||
|
|
@ -106,6 +116,17 @@ type DBConfig struct {
|
||||||
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
|
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolValue(v *bool, def bool) bool {
|
||||||
|
if v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
|
|
@ -142,6 +163,16 @@ func defaultConfig() Config {
|
||||||
UnitForce: "kN",
|
UnitForce: "kN",
|
||||||
UnitPct: "%",
|
UnitPct: "%",
|
||||||
},
|
},
|
||||||
|
Modules: ModulesConfig{
|
||||||
|
ShowHeaderControls: boolPtr(true),
|
||||||
|
ShowVerdict: boolPtr(true),
|
||||||
|
ShowSummaryBar: boolPtr(true),
|
||||||
|
ShowOverview: boolPtr(true),
|
||||||
|
ShowIntelligence: boolPtr(true),
|
||||||
|
ShowAlarmTimeline: boolPtr(true),
|
||||||
|
ShowGauges: boolPtr(true),
|
||||||
|
ShowTrendChart: boolPtr(true),
|
||||||
|
},
|
||||||
DB: DBConfig{
|
DB: DBConfig{
|
||||||
Path: "force_monitor.db",
|
Path: "force_monitor.db",
|
||||||
BusyTimeoutMs: 5000,
|
BusyTimeoutMs: 5000,
|
||||||
|
|
@ -158,7 +189,7 @@ func defaultConfig() Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config normalisation helpers — eliminate repetitive if-chains
|
// Config normalisation helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func setIfZeroF(dst *float64, def float64) {
|
func setIfZeroF(dst *float64, def float64) {
|
||||||
|
|
@ -179,6 +210,13 @@ func setIfEmpty(dst *string, def string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setIfNilBool(dst **bool, def bool) {
|
||||||
|
if *dst == nil {
|
||||||
|
v := def
|
||||||
|
*dst = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeConfig(cfg *Config) {
|
func normalizeConfig(cfg *Config) {
|
||||||
def := defaultConfig()
|
def := defaultConfig()
|
||||||
|
|
||||||
|
|
@ -186,12 +224,13 @@ func normalizeConfig(cfg *Config) {
|
||||||
|
|
||||||
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
|
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
|
||||||
setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
|
setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
|
||||||
|
setIfZeroI(&cfg.PLC.Rack, def.PLC.Rack)
|
||||||
|
setIfZeroI(&cfg.PLC.Slot, def.PLC.Slot)
|
||||||
setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
|
setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
|
||||||
setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
|
setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
|
||||||
setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
|
setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
|
||||||
setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)
|
setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)
|
||||||
|
|
||||||
// Legacy threshold key migration (warning_kn / critical_kn / max_kn)
|
|
||||||
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
|
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
|
||||||
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
|
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +253,6 @@ func normalizeConfig(cfg *Config) {
|
||||||
|
|
||||||
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
|
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
|
||||||
|
|
||||||
// Legacy press tonnage key migration (max_tonnage lowercase)
|
|
||||||
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
||||||
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
|
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +265,15 @@ func normalizeConfig(cfg *Config) {
|
||||||
setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
|
setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
|
||||||
setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)
|
setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)
|
||||||
|
|
||||||
|
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))
|
||||||
|
setIfNilBool(&cfg.Modules.ShowTrendChart, boolValue(def.Modules.ShowTrendChart, true))
|
||||||
|
|
||||||
setIfEmpty(&cfg.DB.Path, def.DB.Path)
|
setIfEmpty(&cfg.DB.Path, def.DB.Path)
|
||||||
setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
|
setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
|
||||||
setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
|
setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
|
||||||
|
|
@ -388,6 +435,15 @@ type PageData struct {
|
||||||
PollMs int
|
PollMs int
|
||||||
DefaultWindow string
|
DefaultWindow string
|
||||||
DefaultTrendWindow string
|
DefaultTrendWindow string
|
||||||
|
|
||||||
|
ShowHeaderControls bool
|
||||||
|
ShowVerdict bool
|
||||||
|
ShowSummaryBar bool
|
||||||
|
ShowOverview bool
|
||||||
|
ShowIntelligence bool
|
||||||
|
ShowAlarmTimeline bool
|
||||||
|
ShowGauges bool
|
||||||
|
ShowTrendChart bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type NumericStats struct {
|
type NumericStats struct {
|
||||||
|
|
@ -430,11 +486,11 @@ var (
|
||||||
alarmCh chan AlarmEvent
|
alarmCh chan AlarmEvent
|
||||||
alarmTracker AlarmTracker
|
alarmTracker AlarmTracker
|
||||||
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
|
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
|
||||||
cachedUI []byte // pre-rendered template (PageData is immutable after startup)
|
cachedUI []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Force calculation — accepts maxTonnage explicitly (testable, no global dep)
|
// Force calculation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
|
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
|
||||||
|
|
@ -509,9 +565,6 @@ func enqueueAlarm(a AlarmEvent) {
|
||||||
// Database initialisation
|
// Database initialisation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ensureColumn adds a column to tableName if it does not already exist.
|
|
||||||
// NOTE: tableName and columnName are always hardcoded call-site constants —
|
|
||||||
// never derived from user input — so fmt.Sprintf is safe here.
|
|
||||||
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
|
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
|
||||||
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -646,7 +699,7 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DB writer goroutines — both now respect config values and context shutdown
|
// DB writer goroutines
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func startDBWriter(ctx context.Context, database *sql.DB) {
|
func startDBWriter(ctx context.Context, database *sql.DB) {
|
||||||
|
|
@ -716,7 +769,6 @@ func startDBWriter(ctx context.Context, database *sql.DB) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Drain any remaining queued samples before exit
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case s := <-sampleCh:
|
case s := <-sampleCh:
|
||||||
|
|
@ -738,7 +790,6 @@ func startDBWriter(ctx context.Context, database *sql.DB) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
||||||
// BUG FIX: was hardcoded 1000ms / 32 — now uses the same config values as startDBWriter
|
|
||||||
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
|
@ -803,7 +854,6 @@ func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Drain remaining alarm events before exit
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case a := <-alarmCh:
|
case a := <-alarmCh:
|
||||||
|
|
@ -1046,7 +1096,6 @@ func startPLCPoller(ctx context.Context) {
|
||||||
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Check for shutdown before attempting a new connection
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
|
@ -1073,8 +1122,6 @@ func startPLCPoller(ctx context.Context) {
|
||||||
client := gos7.NewClient(handler)
|
client := gos7.NewClient(handler)
|
||||||
log.Println("PLC connected successfully")
|
log.Println("PLC connected successfully")
|
||||||
|
|
||||||
// BUG FIX: buf was allocated inside the inner loop, causing a heap
|
|
||||||
// allocation every poll cycle. Moved outside — reused each iteration.
|
|
||||||
buf := make([]byte, 8)
|
buf := make([]byte, 8)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -1277,13 +1324,6 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
|
||||||
// Trend / stability classification
|
// Trend / stability classification
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// classifyDirection is a single generic direction classifier that replaces
|
|
||||||
// the two near-identical classifyForceDirection / classifyImbalanceDirection
|
|
||||||
// functions that existed previously.
|
|
||||||
//
|
|
||||||
// stableThreshold — abs(delta) below this value → "stable"
|
|
||||||
// posLabel — label when delta > threshold (e.g. "rising", "worsening")
|
|
||||||
// negLabel — label when delta < -threshold (e.g. "falling", "improving")
|
|
||||||
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
|
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
|
||||||
if oldCount < 3 || newCount < 3 {
|
if oldCount < 3 || newCount < 3 {
|
||||||
return "insufficient_data"
|
return "insufficient_data"
|
||||||
|
|
@ -1538,14 +1578,11 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveUI serves the pre-rendered UI page. PageData is derived solely from
|
|
||||||
// the immutable config, so we render the template once at startup and reuse.
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write(cachedUI)
|
_, _ = w.Write(cachedUI)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initCachedUI renders the HTML template once at startup.
|
|
||||||
func initCachedUI() {
|
func initCachedUI() {
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Title: cfg.UI.Title,
|
Title: cfg.UI.Title,
|
||||||
|
|
@ -1563,6 +1600,15 @@ func initCachedUI() {
|
||||||
PollMs: cfg.PLC.PollMs,
|
PollMs: cfg.PLC.PollMs,
|
||||||
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
||||||
DefaultTrendWindow: "15m",
|
DefaultTrendWindow: "15m",
|
||||||
|
|
||||||
|
ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true),
|
||||||
|
ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true),
|
||||||
|
ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true),
|
||||||
|
ShowOverview: boolValue(cfg.Modules.ShowOverview, true),
|
||||||
|
ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true),
|
||||||
|
ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true),
|
||||||
|
ShowGauges: boolValue(cfg.Modules.ShowGauges, true),
|
||||||
|
ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true),
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
@ -1600,7 +1646,7 @@ func main() {
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
|
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
|
||||||
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize) // BUG FIX: was hardcoded 512
|
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize)
|
||||||
|
|
||||||
initCachedUI()
|
initCachedUI()
|
||||||
|
|
||||||
|
|
@ -1611,7 +1657,6 @@ func main() {
|
||||||
cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
|
cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
|
||||||
log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)
|
log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)
|
||||||
|
|
||||||
// Graceful shutdown via SIGINT / SIGTERM
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
|
@ -1622,8 +1667,13 @@ func main() {
|
||||||
go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
|
go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
|
||||||
go func() { defer wg.Done(); startPLCPoller(ctx) }()
|
go func() { defer wg.Done(); startPLCPoller(ctx) }()
|
||||||
|
|
||||||
|
staticRoot, err := fs.Sub(staticFiles, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to mount embedded static files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
||||||
mux.HandleFunc("/", serveUI)
|
mux.HandleFunc("/", serveUI)
|
||||||
mux.HandleFunc("/api/data", apiData)
|
mux.HandleFunc("/api/data", apiData)
|
||||||
mux.HandleFunc("/api/history", apiHistory)
|
mux.HandleFunc("/api/history", apiHistory)
|
||||||
|
|
@ -2025,10 +2075,12 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 xl:items-end">
|
<div class="flex flex-col gap-4 xl:items-end">
|
||||||
|
{{if .ShowHeaderControls}}
|
||||||
<div class="flex flex-wrap gap-3 justify-end">
|
<div class="flex flex-wrap gap-3 justify-end">
|
||||||
<button id="theme-toggle" class="control-btn" type="button">Light theme</button>
|
<button id="theme-toggle" class="control-btn" type="button">Light theme</button>
|
||||||
<button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
|
<button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<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="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 class="flex items-center gap-3">
|
||||||
|
|
@ -2043,6 +2095,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .ShowVerdict}}
|
||||||
<div id="verdict-card" class="verdict-card neutral mb-8">
|
<div id="verdict-card" class="verdict-card neutral mb-8">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
|
<div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
|
||||||
|
|
@ -2050,7 +2103,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
|
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowSummaryBar}}
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
|
||||||
<div id="summary-force-card" class="summary-card neutral">
|
<div id="summary-force-card" class="summary-card neutral">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|
@ -2085,7 +2140,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
|
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowOverview}}
|
||||||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
||||||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2122,7 +2179,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowIntelligence}}
|
||||||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
<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 class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2184,7 +2243,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowAlarmTimeline}}
|
||||||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
<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 class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2214,7 +2275,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowGauges}}
|
||||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-8 mb-8">
|
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-8 mb-8">
|
||||||
<div id="card-l" class="glass border border-white/10 rounded-3xl p-6 md:p-8 transition-all duration-300">
|
<div id="card-l" class="glass border border-white/10 rounded-3xl p-6 md:p-8 transition-all duration-300">
|
||||||
<div class="flex justify-between items-start mb-4 gap-6">
|
<div class="flex justify-between items-start mb-4 gap-6">
|
||||||
|
|
@ -2260,7 +2323,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ShowTrendChart}}
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
|
<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 class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
|
||||||
|
|
@ -2289,6 +2354,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -2304,6 +2370,15 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
|
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
|
||||||
const STALE_MS = Math.max(POLL_MS * 4, 2500);
|
const STALE_MS = Math.max(POLL_MS * 4, 2500);
|
||||||
|
|
||||||
|
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}};
|
||||||
|
const SHOW_TREND_CHART = {{if .ShowTrendChart}}true{{else}}false{{end}};
|
||||||
|
|
||||||
const START_ANGLE = Math.PI * 0.75;
|
const START_ANGLE = Math.PI * 0.75;
|
||||||
const END_ANGLE = Math.PI * 2.25;
|
const END_ANGLE = Math.PI * 2.25;
|
||||||
|
|
||||||
|
|
@ -2332,6 +2407,16 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTextById(id, text) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextBySelector(selector, text) {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
function colorMix(c1, c2, t) {
|
function colorMix(c1, c2, t) {
|
||||||
return {
|
return {
|
||||||
r: Math.round(lerp(c1.r, c2.r, t)),
|
r: Math.round(lerp(c1.r, c2.r, t)),
|
||||||
|
|
@ -2416,6 +2501,8 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
|
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
|
||||||
|
if (!SHOW_GAUGES) return;
|
||||||
|
|
||||||
const canvas = document.getElementById(canvasId);
|
const canvas = document.getElementById(canvasId);
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
|
|
@ -2563,6 +2650,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
function setConnectionIndicator(connected, stale) {
|
function setConnectionIndicator(connected, stale) {
|
||||||
const dot = document.getElementById('dot');
|
const dot = document.getElementById('dot');
|
||||||
const text = document.getElementById('status-text');
|
const text = document.getElementById('status-text');
|
||||||
|
if (!dot || !text) return;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
|
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
|
||||||
|
|
@ -2584,10 +2672,13 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyChannelState(side, percentValue) {
|
function applyChannelState(side, percentValue) {
|
||||||
|
if (!SHOW_GAUGES) return;
|
||||||
|
|
||||||
const zone = getZone(percentValue);
|
const zone = getZone(percentValue);
|
||||||
const card = document.getElementById('card-' + side);
|
const card = document.getElementById('card-' + side);
|
||||||
const led = document.getElementById('led-' + side);
|
const led = document.getElementById('led-' + side);
|
||||||
const stateText = document.getElementById('state-' + side);
|
const stateText = document.getElementById('state-' + side);
|
||||||
|
if (!card || !led || !stateText) return;
|
||||||
|
|
||||||
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
|
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
|
||||||
|
|
||||||
|
|
@ -2623,10 +2714,13 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSummaryCard(kind, zone, text, value) {
|
function setSummaryCard(kind, zone, text, value) {
|
||||||
|
if (!SHOW_SUMMARY_BAR) return;
|
||||||
|
|
||||||
const card = document.getElementById('summary-' + kind + '-card');
|
const card = document.getElementById('summary-' + kind + '-card');
|
||||||
const dot = document.getElementById('summary-' + kind + '-dot');
|
const dot = document.getElementById('summary-' + kind + '-dot');
|
||||||
const status = document.getElementById('summary-' + kind + '-text');
|
const status = document.getElementById('summary-' + kind + '-text');
|
||||||
const val = document.getElementById('summary-' + kind + '-value');
|
const val = document.getElementById('summary-' + kind + '-value');
|
||||||
|
if (!card || !dot || !status || !val) return;
|
||||||
|
|
||||||
card.className = 'summary-card ' + zone;
|
card.className = 'summary-card ' + zone;
|
||||||
dot.className = 'summary-dot ' + zone;
|
dot.className = 'summary-dot ' + zone;
|
||||||
|
|
@ -2636,15 +2730,21 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVerdict(zone, statusText, reasonText) {
|
function setVerdict(zone, statusText, reasonText) {
|
||||||
|
if (!SHOW_VERDICT) return;
|
||||||
|
|
||||||
const card = document.getElementById('verdict-card');
|
const card = document.getElementById('verdict-card');
|
||||||
const status = document.getElementById('verdict-status');
|
const status = document.getElementById('verdict-status');
|
||||||
const reason = document.getElementById('verdict-reason');
|
const reason = document.getElementById('verdict-reason');
|
||||||
|
if (!card || !status || !reason) return;
|
||||||
|
|
||||||
card.className = 'verdict-card ' + zone;
|
card.className = 'verdict-card ' + zone;
|
||||||
status.textContent = statusText;
|
status.textContent = statusText;
|
||||||
reason.textContent = reasonText;
|
reason.textContent = reasonText;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) {
|
function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) {
|
||||||
|
if (!SHOW_SUMMARY_BAR) return;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
setSummaryCard('force', 'neutral', 'NO DATA', '--');
|
setSummaryCard('force', 'neutral', 'NO DATA', '--');
|
||||||
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
|
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
|
||||||
|
|
@ -2670,6 +2770,8 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
|
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
|
||||||
|
if (!SHOW_VERDICT) return;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
setVerdict('critical', 'OFFLINE', 'No PLC communication');
|
setVerdict('critical', 'OFFLINE', 'No PLC communication');
|
||||||
return;
|
return;
|
||||||
|
|
@ -2712,6 +2814,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) {
|
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) {
|
||||||
const banner = document.getElementById('alarm-banner');
|
const banner = document.getElementById('alarm-banner');
|
||||||
const text = document.getElementById('alarm-text');
|
const text = document.getElementById('alarm-text');
|
||||||
|
if (!banner || !text) return;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE';
|
text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE';
|
||||||
|
|
@ -2752,6 +2855,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function redrawGauges() {
|
function redrawGauges() {
|
||||||
|
if (!SHOW_GAUGES) return;
|
||||||
if (!latestData) return;
|
if (!latestData) return;
|
||||||
|
|
||||||
const leftPercent = Number(latestData.sila_l) || 0;
|
const leftPercent = Number(latestData.sila_l) || 0;
|
||||||
|
|
@ -2805,9 +2909,13 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function setIntelCard(idPrefix, zone, valueText, subText) {
|
function setIntelCard(idPrefix, zone, valueText, subText) {
|
||||||
|
if (!SHOW_INTELLIGENCE) return;
|
||||||
|
|
||||||
const card = document.getElementById(idPrefix + '-card');
|
const card = document.getElementById(idPrefix + '-card');
|
||||||
const value = document.getElementById(idPrefix + '-value');
|
const value = document.getElementById(idPrefix + '-value');
|
||||||
const sub = document.getElementById(idPrefix + '-sub');
|
const sub = document.getElementById(idPrefix + '-sub');
|
||||||
|
if (!card || !value || !sub) return;
|
||||||
|
|
||||||
card.className = 'intel-card ' + zone;
|
card.className = 'intel-card ' + zone;
|
||||||
value.innerHTML = valueText;
|
value.innerHTML = valueText;
|
||||||
sub.innerHTML = subText;
|
sub.innerHTML = subText;
|
||||||
|
|
@ -2882,7 +2990,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChartTheme() {
|
function updateChartTheme() {
|
||||||
if (!lineChart) return;
|
if (!SHOW_TREND_CHART || !lineChart) return;
|
||||||
|
|
||||||
const light = isLightTheme();
|
const light = isLightTheme();
|
||||||
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
|
const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
|
||||||
|
|
@ -2928,19 +3036,23 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
|
|
||||||
setConnectionIndicator(connected, stale);
|
setConnectionIndicator(connected, stale);
|
||||||
|
|
||||||
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
|
if (SHOW_GAUGES) {
|
||||||
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
|
setTextBySelector('#digital-l .percent', leftPercent.toFixed(1));
|
||||||
|
setTextBySelector('#digital-l .kn', leftKN.toFixed(1) + ' ' + UNIT_FORCE);
|
||||||
|
setTextBySelector('#digital-r .percent', rightPercent.toFixed(1));
|
||||||
|
setTextBySelector('#digital-r .kn', rightKN.toFixed(1) + ' ' + UNIT_FORCE);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
|
if (SHOW_OVERVIEW) {
|
||||||
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
|
setTextById('sum-percent', sumPercent.toFixed(1));
|
||||||
|
setTextById('sum-kn', sumKN.toFixed(1));
|
||||||
|
setTextById('imbalance-pct', imbalance.toFixed(1));
|
||||||
|
setTextById('bias-pct', bias.toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
|
setTextById('last-update', formatLastUpdate(d.last_update));
|
||||||
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
|
setTextById('dropped-samples', String(d.dropped_samples || 0));
|
||||||
document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1);
|
setTextById('dropped-events', String(d.dropped_events || 0));
|
||||||
document.getElementById('bias-pct').textContent = bias.toFixed(1);
|
|
||||||
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
|
|
||||||
document.getElementById('dropped-samples').textContent = String(d.dropped_samples || 0);
|
|
||||||
document.getElementById('dropped-events').textContent = String(d.dropped_events || 0);
|
|
||||||
|
|
||||||
applyChannelState('l', leftPercent);
|
applyChannelState('l', leftPercent);
|
||||||
applyChannelState('r', rightPercent);
|
applyChannelState('r', rightPercent);
|
||||||
|
|
@ -2958,6 +3070,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHistory() {
|
async function fetchHistory() {
|
||||||
|
if (!SHOW_TREND_CHART || !lineChart) return;
|
||||||
if (historyBusy) return;
|
if (historyBusy) return;
|
||||||
historyBusy = true;
|
historyBusy = true;
|
||||||
|
|
||||||
|
|
@ -2987,6 +3100,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTrend() {
|
async function fetchTrend() {
|
||||||
|
if (!SHOW_INTELLIGENCE) return;
|
||||||
if (trendBusy) return;
|
if (trendBusy) return;
|
||||||
trendBusy = true;
|
trendBusy = true;
|
||||||
|
|
||||||
|
|
@ -3057,6 +3171,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAlarms() {
|
async function fetchAlarms() {
|
||||||
|
if (!SHOW_ALARM_TIMELINE) return;
|
||||||
if (alarmsBusy) return;
|
if (alarmsBusy) return;
|
||||||
alarmsBusy = true;
|
alarmsBusy = true;
|
||||||
|
|
||||||
|
|
@ -3066,6 +3181,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
const events = Array.isArray(d.events) ? d.events : [];
|
const events = Array.isArray(d.events) ? d.events : [];
|
||||||
const body = document.getElementById('alarm-table-body');
|
const body = document.getElementById('alarm-table-body');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
|
body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
|
||||||
|
|
@ -3110,12 +3226,14 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWindow(value) {
|
function useWindow(value) {
|
||||||
|
if (!SHOW_TREND_CHART) return;
|
||||||
currentWindow = value;
|
currentWindow = value;
|
||||||
setActiveWindowButton(value);
|
setActiveWindowButton(value);
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTrendWindow(value) {
|
function useTrendWindow(value) {
|
||||||
|
if (!SHOW_INTELLIGENCE) return;
|
||||||
currentTrendWindow = value;
|
currentTrendWindow = value;
|
||||||
setActiveTrendWindowButton(value);
|
setActiveTrendWindowButton(value);
|
||||||
fetchTrend();
|
fetchTrend();
|
||||||
|
|
@ -3127,8 +3245,12 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
setActiveWindowButton(DEFAULT_WINDOW);
|
setActiveWindowButton(DEFAULT_WINDOW);
|
||||||
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
||||||
|
|
||||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
if (SHOW_HEADER_CONTROLS) {
|
||||||
document.getElementById('fullscreen-toggle').addEventListener('click', toggleFullscreen);
|
const themeBtn = document.getElementById('theme-toggle');
|
||||||
|
const fsBtn = document.getElementById('fullscreen-toggle');
|
||||||
|
if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
|
||||||
|
if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.window-btn').forEach(btn => {
|
document.querySelectorAll('.window-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => useWindow(btn.dataset.window));
|
btn.addEventListener('click', () => useWindow(btn.dataset.window));
|
||||||
|
|
@ -3138,97 +3260,110 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
|
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('apply-window').addEventListener('click', () => {
|
const applyWindowBtn = document.getElementById('apply-window');
|
||||||
const val = document.getElementById('custom-window').value.trim();
|
if (applyWindowBtn) {
|
||||||
if (!val) return;
|
applyWindowBtn.addEventListener('click', () => {
|
||||||
currentWindow = val;
|
const input = document.getElementById('custom-window');
|
||||||
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
|
const val = input ? input.value.trim() : '';
|
||||||
fetchHistory();
|
if (!val) return;
|
||||||
});
|
currentWindow = val;
|
||||||
|
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
fetchHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('apply-trend-window').addEventListener('click', () => {
|
const applyTrendBtn = document.getElementById('apply-trend-window');
|
||||||
const val = document.getElementById('custom-trend-window').value.trim();
|
if (applyTrendBtn) {
|
||||||
if (!val) return;
|
applyTrendBtn.addEventListener('click', () => {
|
||||||
currentTrendWindow = val;
|
const input = document.getElementById('custom-trend-window');
|
||||||
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
|
const val = input ? input.value.trim() : '';
|
||||||
fetchTrend();
|
if (!val) return;
|
||||||
});
|
currentTrendWindow = val;
|
||||||
|
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
fetchTrend();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
||||||
updateFullscreenButton();
|
updateFullscreenButton();
|
||||||
|
|
||||||
lineChart = new Chart(document.getElementById('lineChart'), {
|
if (SHOW_TREND_CHART) {
|
||||||
type: 'line',
|
const chartCanvas = document.getElementById('lineChart');
|
||||||
data: {
|
if (chartCanvas) {
|
||||||
labels: [],
|
lineChart = new Chart(chartCanvas, {
|
||||||
datasets: [
|
type: 'line',
|
||||||
{
|
data: {
|
||||||
label: 'Levi peak %',
|
labels: [],
|
||||||
borderColor: '#22d3ee',
|
datasets: [
|
||||||
backgroundColor: 'rgba(34,211,238,0.10)',
|
{
|
||||||
borderWidth: 3,
|
label: 'Levi peak %',
|
||||||
tension: 0.22,
|
borderColor: '#22d3ee',
|
||||||
pointRadius: 0,
|
backgroundColor: 'rgba(34,211,238,0.10)',
|
||||||
data: []
|
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: []
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
options: {
|
||||||
label: 'Desni peak %',
|
responsive: true,
|
||||||
borderColor: '#c084fc',
|
maintainAspectRatio: false,
|
||||||
backgroundColor: 'rgba(192,132,252,0.10)',
|
interaction: { mode: 'index', intersect: false },
|
||||||
borderWidth: 3,
|
animation: false,
|
||||||
tension: 0.22,
|
scales: {
|
||||||
pointRadius: 0,
|
x: {
|
||||||
data: []
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
},
|
ticks: { color: '#a1a1aa', maxTicksLimit: 18 }
|
||||||
{
|
},
|
||||||
label: 'Warning limit',
|
y: {
|
||||||
borderColor: 'rgba(245,158,11,0.95)',
|
min: 0,
|
||||||
borderWidth: 2,
|
max: GAUGE_MAX_PERCENT,
|
||||||
pointRadius: 0,
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
borderDash: [8, 6],
|
ticks: { color: '#a1a1aa', stepSize: 10 }
|
||||||
tension: 0,
|
}
|
||||||
data: []
|
},
|
||||||
},
|
plugins: {
|
||||||
{
|
legend: { position: 'top', labels: { color: '#f4f4f5' } },
|
||||||
label: 'Critical limit',
|
tooltip: {
|
||||||
borderColor: 'rgba(239,68,68,0.95)',
|
backgroundColor: 'rgba(9,9,11,0.96)',
|
||||||
borderWidth: 2,
|
titleColor: '#f4f4f5',
|
||||||
pointRadius: 0,
|
bodyColor: '#f4f4f5'
|
||||||
borderDash: [8, 6],
|
}
|
||||||
tension: 0,
|
}
|
||||||
data: []
|
|
||||||
}
|
}
|
||||||
]
|
});
|
||||||
},
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateChartTheme();
|
updateChartTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchLiveData();
|
fetchLiveData();
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
|
|
@ -3236,9 +3371,18 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
fetchAlarms();
|
fetchAlarms();
|
||||||
|
|
||||||
setInterval(fetchLiveData, POLL_MS);
|
setInterval(fetchLiveData, POLL_MS);
|
||||||
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
|
|
||||||
setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
|
if (SHOW_TREND_CHART) {
|
||||||
setInterval(fetchAlarms, 2500);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', redrawGauges);
|
window.addEventListener('resize', redrawGauges);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue