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"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
|
|
@ -29,7 +30,7 @@ import (
|
|||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const version = "0.7.1"
|
||||
const version = "0.8.0"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config structs
|
||||
|
|
@ -42,6 +43,7 @@ type Config struct {
|
|||
Trend TrendConfig `yaml:"trend"`
|
||||
Press PressConfig `yaml:"press"`
|
||||
UI UIConfig `yaml:"ui"`
|
||||
Modules ModulesConfig `yaml:"modules"`
|
||||
DB DBConfig `yaml:"db"`
|
||||
}
|
||||
|
||||
|
|
@ -76,9 +78,6 @@ type TrendConfig struct {
|
|||
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 {
|
||||
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
|
||||
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
||||
|
|
@ -93,6 +92,17 @@ type UIConfig struct {
|
|||
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 {
|
||||
Path string `yaml:"path"`
|
||||
BusyTimeoutMs int `yaml:"busy_timeout_ms"`
|
||||
|
|
@ -106,6 +116,17 @@ type DBConfig struct {
|
|||
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 {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
|
|
@ -142,6 +163,16 @@ func defaultConfig() Config {
|
|||
UnitForce: "kN",
|
||||
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{
|
||||
Path: "force_monitor.db",
|
||||
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) {
|
||||
|
|
@ -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) {
|
||||
def := defaultConfig()
|
||||
|
||||
|
|
@ -186,12 +224,13 @@ func normalizeConfig(cfg *Config) {
|
|||
|
||||
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
|
||||
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.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
|
||||
setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
|
||||
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 {
|
||||
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
|
||||
}
|
||||
|
|
@ -214,7 +253,6 @@ func normalizeConfig(cfg *Config) {
|
|||
|
||||
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
|
||||
|
||||
// Legacy press tonnage key migration (max_tonnage lowercase)
|
||||
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
||||
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
|
||||
}
|
||||
|
|
@ -227,6 +265,15 @@ func normalizeConfig(cfg *Config) {
|
|||
setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
|
||||
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)
|
||||
setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
|
||||
setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
|
||||
|
|
@ -388,6 +435,15 @@ type PageData struct {
|
|||
PollMs int
|
||||
DefaultWindow string
|
||||
DefaultTrendWindow string
|
||||
|
||||
ShowHeaderControls bool
|
||||
ShowVerdict bool
|
||||
ShowSummaryBar bool
|
||||
ShowOverview bool
|
||||
ShowIntelligence bool
|
||||
ShowAlarmTimeline bool
|
||||
ShowGauges bool
|
||||
ShowTrendChart bool
|
||||
}
|
||||
|
||||
type NumericStats struct {
|
||||
|
|
@ -430,11 +486,11 @@ var (
|
|||
alarmCh chan AlarmEvent
|
||||
alarmTracker AlarmTracker
|
||||
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) {
|
||||
|
|
@ -509,9 +565,6 @@ func enqueueAlarm(a AlarmEvent) {
|
|||
// Database initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ensureColumn adds a column to tableName if it does not already exist.
|
||||
// NOTE: tableName and columnName are always hardcoded call-site constants —
|
||||
// never derived from user input — so fmt.Sprintf is safe here.
|
||||
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
|
||||
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
||||
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) {
|
||||
|
|
@ -716,7 +769,6 @@ func startDBWriter(ctx context.Context, database *sql.DB) {
|
|||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain any remaining queued samples before exit
|
||||
for {
|
||||
select {
|
||||
case s := <-sampleCh:
|
||||
|
|
@ -738,7 +790,6 @@ func startDBWriter(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)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
|
@ -803,7 +854,6 @@ func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
|||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain remaining alarm events before exit
|
||||
for {
|
||||
select {
|
||||
case a := <-alarmCh:
|
||||
|
|
@ -1046,7 +1096,6 @@ func startPLCPoller(ctx context.Context) {
|
|||
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
||||
|
||||
for {
|
||||
// Check for shutdown before attempting a new connection
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
|
@ -1073,8 +1122,6 @@ func startPLCPoller(ctx context.Context) {
|
|||
client := gos7.NewClient(handler)
|
||||
log.Println("PLC connected successfully")
|
||||
|
||||
// BUG FIX: buf was allocated inside the inner loop, causing a heap
|
||||
// allocation every poll cycle. Moved outside — reused each iteration.
|
||||
buf := make([]byte, 8)
|
||||
|
||||
for {
|
||||
|
|
@ -1277,13 +1324,6 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
|
|||
// 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 {
|
||||
if oldCount < 3 || newCount < 3 {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(cachedUI)
|
||||
}
|
||||
|
||||
// initCachedUI renders the HTML template once at startup.
|
||||
func initCachedUI() {
|
||||
data := PageData{
|
||||
Title: cfg.UI.Title,
|
||||
|
|
@ -1563,6 +1600,15 @@ func initCachedUI() {
|
|||
PollMs: cfg.PLC.PollMs,
|
||||
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
||||
DefaultTrendWindow: "15m",
|
||||
|
||||
ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true),
|
||||
ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true),
|
||||
ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true),
|
||||
ShowOverview: boolValue(cfg.Modules.ShowOverview, true),
|
||||
ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true),
|
||||
ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true),
|
||||
ShowGauges: boolValue(cfg.Modules.ShowGauges, true),
|
||||
ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -1600,7 +1646,7 @@ func main() {
|
|||
defer db.Close()
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -1611,7 +1657,6 @@ func main() {
|
|||
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)
|
||||
|
||||
// Graceful shutdown via SIGINT / SIGTERM
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
|
|
@ -1622,8 +1667,13 @@ func main() {
|
|||
go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
|
||||
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.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
|
||||
mux.HandleFunc("/", serveUI)
|
||||
mux.HandleFunc("/api/data", apiData)
|
||||
mux.HandleFunc("/api/history", apiHistory)
|
||||
|
|
@ -2025,10 +2075,12 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:items-end">
|
||||
{{if .ShowHeaderControls}}
|
||||
<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>
|
||||
{{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="flex items-center gap-3">
|
||||
|
|
@ -2043,6 +2095,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ShowVerdict}}
|
||||
<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>
|
||||
|
|
@ -2050,7 +2103,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
<div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowSummaryBar}}
|
||||
<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">
|
||||
|
|
@ -2085,7 +2140,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowOverview}}
|
||||
<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>
|
||||
|
|
@ -2122,7 +2179,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowIntelligence}}
|
||||
<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>
|
||||
|
|
@ -2184,7 +2243,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowAlarmTimeline}}
|
||||
<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>
|
||||
|
|
@ -2214,7 +2275,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowGauges}}
|
||||
<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 class="flex justify-between items-start mb-4 gap-6">
|
||||
|
|
@ -2260,7 +2323,9 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ShowTrendChart}}
|
||||
<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">
|
||||
|
|
@ -2289,6 +2354,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
|
@ -2304,6 +2370,15 @@ const uiHTML = `<!DOCTYPE html>
|
|||
const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
|
||||
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 END_ANGLE = Math.PI * 2.25;
|
||||
|
||||
|
|
@ -2332,6 +2407,16 @@ const uiHTML = `<!DOCTYPE html>
|
|||
.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) {
|
||||
return {
|
||||
r: Math.round(lerp(c1.r, c2.r, t)),
|
||||
|
|
@ -2416,6 +2501,8 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
|
||||
if (!SHOW_GAUGES) return;
|
||||
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
|
|
@ -2563,6 +2650,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
function setConnectionIndicator(connected, stale) {
|
||||
const dot = document.getElementById('dot');
|
||||
const text = document.getElementById('status-text');
|
||||
if (!dot || !text) return;
|
||||
|
||||
if (!connected) {
|
||||
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) {
|
||||
if (!SHOW_GAUGES) return;
|
||||
|
||||
const zone = getZone(percentValue);
|
||||
const card = document.getElementById('card-' + side);
|
||||
const led = document.getElementById('led-' + side);
|
||||
const stateText = document.getElementById('state-' + side);
|
||||
if (!card || !led || !stateText) return;
|
||||
|
||||
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) {
|
||||
if (!SHOW_SUMMARY_BAR) return;
|
||||
|
||||
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');
|
||||
if (!card || !dot || !status || !val) return;
|
||||
|
||||
card.className = 'summary-card ' + zone;
|
||||
dot.className = 'summary-dot ' + zone;
|
||||
|
|
@ -2636,15 +2730,21 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function setVerdict(zone, statusText, reasonText) {
|
||||
if (!SHOW_VERDICT) return;
|
||||
|
||||
const card = document.getElementById('verdict-card');
|
||||
const status = document.getElementById('verdict-status');
|
||||
const reason = document.getElementById('verdict-reason');
|
||||
if (!card || !status || !reason) return;
|
||||
|
||||
card.className = 'verdict-card ' + zone;
|
||||
status.textContent = statusText;
|
||||
reason.textContent = reasonText;
|
||||
}
|
||||
|
||||
function updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance) {
|
||||
if (!SHOW_SUMMARY_BAR) return;
|
||||
|
||||
if (!connected) {
|
||||
setSummaryCard('force', 'neutral', 'NO DATA', '--');
|
||||
setSummaryCard('imbalance', 'neutral', 'NO DATA', '--');
|
||||
|
|
@ -2670,6 +2770,8 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
|
||||
if (!SHOW_VERDICT) return;
|
||||
|
||||
if (!connected) {
|
||||
setVerdict('critical', 'OFFLINE', 'No PLC communication');
|
||||
return;
|
||||
|
|
@ -2712,6 +2814,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected, stale) {
|
||||
const banner = document.getElementById('alarm-banner');
|
||||
const text = document.getElementById('alarm-text');
|
||||
if (!banner || !text) return;
|
||||
|
||||
if (!connected) {
|
||||
text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE';
|
||||
|
|
@ -2752,6 +2855,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function redrawGauges() {
|
||||
if (!SHOW_GAUGES) return;
|
||||
if (!latestData) return;
|
||||
|
||||
const leftPercent = Number(latestData.sila_l) || 0;
|
||||
|
|
@ -2805,9 +2909,13 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function setIntelCard(idPrefix, zone, valueText, subText) {
|
||||
if (!SHOW_INTELLIGENCE) return;
|
||||
|
||||
const card = document.getElementById(idPrefix + '-card');
|
||||
const value = document.getElementById(idPrefix + '-value');
|
||||
const sub = document.getElementById(idPrefix + '-sub');
|
||||
if (!card || !value || !sub) return;
|
||||
|
||||
card.className = 'intel-card ' + zone;
|
||||
value.innerHTML = valueText;
|
||||
sub.innerHTML = subText;
|
||||
|
|
@ -2882,7 +2990,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function updateChartTheme() {
|
||||
if (!lineChart) return;
|
||||
if (!SHOW_TREND_CHART || !lineChart) return;
|
||||
|
||||
const light = isLightTheme();
|
||||
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);
|
||||
|
||||
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
|
||||
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
|
||||
if (SHOW_GAUGES) {
|
||||
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);
|
||||
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
|
||||
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));
|
||||
}
|
||||
|
||||
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
|
||||
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
|
||||
document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1);
|
||||
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);
|
||||
setTextById('last-update', formatLastUpdate(d.last_update));
|
||||
setTextById('dropped-samples', String(d.dropped_samples || 0));
|
||||
setTextById('dropped-events', String(d.dropped_events || 0));
|
||||
|
||||
applyChannelState('l', leftPercent);
|
||||
applyChannelState('r', rightPercent);
|
||||
|
|
@ -2958,6 +3070,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
async function fetchHistory() {
|
||||
if (!SHOW_TREND_CHART || !lineChart) return;
|
||||
if (historyBusy) return;
|
||||
historyBusy = true;
|
||||
|
||||
|
|
@ -2987,6 +3100,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
async function fetchTrend() {
|
||||
if (!SHOW_INTELLIGENCE) return;
|
||||
if (trendBusy) return;
|
||||
trendBusy = true;
|
||||
|
||||
|
|
@ -3057,6 +3171,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
async function fetchAlarms() {
|
||||
if (!SHOW_ALARM_TIMELINE) return;
|
||||
if (alarmsBusy) return;
|
||||
alarmsBusy = true;
|
||||
|
||||
|
|
@ -3066,6 +3181,7 @@ const uiHTML = `<!DOCTYPE html>
|
|||
const d = await res.json();
|
||||
const events = Array.isArray(d.events) ? d.events : [];
|
||||
const body = document.getElementById('alarm-table-body');
|
||||
if (!body) return;
|
||||
|
||||
if (events.length === 0) {
|
||||
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) {
|
||||
if (!SHOW_TREND_CHART) return;
|
||||
currentWindow = value;
|
||||
setActiveWindowButton(value);
|
||||
fetchHistory();
|
||||
}
|
||||
|
||||
function useTrendWindow(value) {
|
||||
if (!SHOW_INTELLIGENCE) return;
|
||||
currentTrendWindow = value;
|
||||
setActiveTrendWindowButton(value);
|
||||
fetchTrend();
|
||||
|
|
@ -3127,8 +3245,12 @@ const uiHTML = `<!DOCTYPE html>
|
|||
setActiveWindowButton(DEFAULT_WINDOW);
|
||||
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
|
||||
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
document.getElementById('fullscreen-toggle').addEventListener('click', toggleFullscreen);
|
||||
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);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.window-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => useWindow(btn.dataset.window));
|
||||
|
|
@ -3138,97 +3260,110 @@ const uiHTML = `<!DOCTYPE html>
|
|||
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
|
||||
});
|
||||
|
||||
document.getElementById('apply-window').addEventListener('click', () => {
|
||||
const val = document.getElementById('custom-window').value.trim();
|
||||
if (!val) return;
|
||||
currentWindow = val;
|
||||
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
|
||||
fetchHistory();
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('apply-trend-window').addEventListener('click', () => {
|
||||
const val = document.getElementById('custom-trend-window').value.trim();
|
||||
if (!val) return;
|
||||
currentTrendWindow = val;
|
||||
document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
|
||||
fetchTrend();
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', updateFullscreenButton);
|
||||
updateFullscreenButton();
|
||||
|
||||
lineChart = new Chart(document.getElementById('lineChart'), {
|
||||
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: []
|
||||
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: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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: {
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
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();
|
||||
fetchHistory();
|
||||
|
|
@ -3236,9 +3371,18 @@ const uiHTML = `<!DOCTYPE html>
|
|||
fetchAlarms();
|
||||
|
||||
setInterval(fetchLiveData, POLL_MS);
|
||||
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
|
||||
setInterval(fetchTrend, Math.max(2500, POLL_MS * 5));
|
||||
setInterval(fetchAlarms, 2500);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', redrawGauges);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue