Tonnage-app-IMCO/main.go

3392 lines
117 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log"
"math"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/robinson/gos7"
"gopkg.in/yaml.v3"
)
//go:embed static
var staticFiles embed.FS
const version = "0.8.0"
// ---------------------------------------------------------------------------
// Config structs
// ---------------------------------------------------------------------------
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"`
Modules ModulesConfig `yaml:"modules"`
DB DBConfig `yaml:"db"`
}
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 {
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"`
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 {
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
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"`
}
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"`
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"`
AlarmQueueSize int `yaml:"alarm_queue_size"`
CheckpointPages int `yaml:"checkpoint_pages"`
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{
ListenAddr: ":8080",
},
PLC: PLCConfig{
IP: "192.168.0.1",
DBNum: 1001,
Rack: 0,
Slot: 1,
PollMs: 500,
ConnectTimeoutSec: 5,
IdleTimeoutSec: 5,
ReconnectDelaySec: 5,
},
Thresholds: ThresholdsConfig{
WarningPercent: 80,
CriticalPercent: 95,
GaugeMaxPercent: 130,
ImbalanceWarningPercent: 10,
ImbalanceCriticalPercent: 20,
},
Trend: TrendConfig{
Minutes: 5,
},
Press: PressConfig{
MaxTonnage: 64,
},
UI: UIConfig{
Title: "Force Monitor",
Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
LeftLabel: "LEVI STEBER",
RightLabel: "DESNI STEBER",
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,
BatchSize: 32,
FlushIntervalMs: 1000,
RetentionDays: 30,
MaxChartPoints: 2000,
WriterQueueSize: 4096,
AlarmQueueSize: 512,
CheckpointPages: 1000,
CleanupIntervalHr: 6,
},
}
}
// ---------------------------------------------------------------------------
// Config normalisation helpers
// ---------------------------------------------------------------------------
func setIfZeroF(dst *float64, def float64) {
if *dst <= 0 {
*dst = def
}
}
func setIfZeroI(dst *int, def int) {
if *dst <= 0 {
*dst = def
}
}
func setIfEmpty(dst *string, def string) {
if strings.TrimSpace(*dst) == "" {
*dst = def
}
}
func setIfNilBool(dst **bool, def bool) {
if *dst == nil {
v := def
*dst = &v
}
}
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)
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)
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
}
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)
if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent
}
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
}
setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage)
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)
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)
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)
}
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)
}
data, err := os.ReadFile(configPath)
if err != nil {
return cfg, fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("failed to parse config file: %w", err)
}
normalizeConfig(&cfg)
return cfg, nil
}
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
type Sample struct {
TS time.Time
SilaLPct float32
SilaRPct float32
SilaLKN float32
SilaRKN float32
SumPercent float32
SumKN float32
ImbalancePercent float32
BiasPercent float32
}
type AlarmEvent struct {
TS time.Time
Severity string
Source string
Code string
State string
Message string
Value float64
Limit float64
}
type AppState struct {
sync.RWMutex
Connected bool
SilaL float32
SilaR float32
SilaLkN float32
SilaRkN float32
SumPercent float32
SumkN float32
ImbalancePercent float32
BiasPercent float32
LastUpdate time.Time
DroppedSamples uint64
DroppedEvents uint64
}
type APIState struct {
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"`
DroppedEvents uint64 `json:"dropped_events"`
}
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"`
}
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"`
}
type PageData struct {
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
DefaultTrendWindow string
ShowHeaderControls bool
ShowVerdict bool
ShowSummaryBar bool
ShowOverview bool
ShowIntelligence bool
ShowAlarmTimeline bool
ShowGauges bool
ShowTrendChart bool
}
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
}
// ---------------------------------------------------------------------------
// Package-level singletons
// ---------------------------------------------------------------------------
var (
cfg Config
state AppState
db *sql.DB
sampleCh chan Sample
alarmCh chan AlarmEvent
alarmTracker AlarmTracker
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
cachedUI []byte
)
// ---------------------------------------------------------------------------
// Force calculation
// ---------------------------------------------------------------------------
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
lp := float64(leftPercent)
rp := float64(rightPercent)
sumPct := (lp + rp) / 2.0
left := (lp / 100.0) * (maxTonnage / 2.0)
right := (rp / 100.0) * (maxTonnage / 2.0)
total := (sumPct / 100.0) * maxTonnage
return float32(left), float32(right), float32(sumPct), float32(total)
}
// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------
func snapshotState() APIState {
state.RLock()
defer state.RUnlock()
lastUpdate := ""
if !state.LastUpdate.IsZero() {
lastUpdate = state.LastUpdate.Format(time.RFC3339Nano)
}
return APIState{
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,
DroppedEvents: state.DroppedEvents,
}
}
func markDisconnected(reason string) {
state.Lock()
state.Connected = false
state.Unlock()
maybeLogPLCDisconnected(reason)
}
func enqueueSample(s Sample) {
select {
case sampleCh <- s:
default:
state.Lock()
state.DroppedSamples++
state.Unlock()
}
}
func enqueueAlarm(a AlarmEvent) {
select {
case alarmCh <- a:
default:
state.Lock()
state.DroppedEvents++
state.Unlock()
}
}
// ---------------------------------------------------------------------------
// Database initialisation
// ---------------------------------------------------------------------------
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
var name, ctype string
var notNull int
var dfltValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &ctype, &notNull, &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
}
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)
}
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
database.SetConnMaxLifetime(0)
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,
ts_unix_ns INTEGER NOT NULL DEFAULT 0,
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,
sum_kn REAL NOT NULL,
imbalance_pct REAL NOT NULL DEFAULT 0,
bias_pct REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
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);
`
if _, err := database.Exec(schema); err != nil {
_ = database.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
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)
}
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)
}
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)
}
return database, nil
}
// ---------------------------------------------------------------------------
// DB writer goroutines
// ---------------------------------------------------------------------------
func startDBWriter(ctx context.Context, database *sql.DB) {
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
}
stmt, err := tx.Prepare(`
INSERT INTO samples (
ts, ts_unix_ns, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn,
sum_pct, sum_kn, imbalance_pct, bias_pct
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
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),
s.TS.UTC().UnixNano(),
s.SilaLPct,
s.SilaRPct,
s.SilaLKN,
s.SilaRKN,
s.SumPercent,
s.SumKN,
s.ImbalancePercent,
s.BiasPercent,
)
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]
}
for {
select {
case <-ctx.Done():
for {
select {
case s := <-sampleCh:
batch = append(batch, s)
default:
flush()
return
}
}
case s := <-sampleCh:
batch = append(batch, s)
if len(batch) >= cfg.DB.BatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func startAlarmWriter(ctx context.Context, database *sql.DB) {
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
defer ticker.Stop()
batch := make([]AlarmEvent, 0, cfg.DB.BatchSize)
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 {
case <-ctx.Done():
for {
select {
case a := <-alarmCh:
batch = append(batch, a)
default:
flush()
return
}
}
case a := <-alarmCh:
batch = append(batch, a)
if len(batch) >= cfg.DB.BatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func startDBCleanup(ctx context.Context, database *sql.DB) {
if cfg.DB.RetentionDays <= 0 {
return
}
ticker := time.NewTicker(time.Duration(cfg.DB.CleanupIntervalHr) * time.Hour)
defer ticker.Stop()
cleanup := func() {
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)
}
}
cleanup()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleanup()
}
}
}
// ---------------------------------------------------------------------------
// Alarm zone helpers
// ---------------------------------------------------------------------------
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 {
switch source {
case "imbalance":
if zone == "critical" {
return cfg.Thresholds.ImbalanceCriticalPercent
}
if zone == "warning" {
return cfg.Thresholds.ImbalanceWarningPercent
}
default:
if zone == "critical" {
return cfg.Thresholds.CriticalPercent
}
if zone == "warning" {
return cfg.Thresholds.WarningPercent
}
}
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) {
leftZone := zoneFromValue(float64(s.SilaLPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
rightZone := zoneFromValue(float64(s.SilaRPct), cfg.Thresholds.WarningPercent, cfg.Thresholds.CriticalPercent)
imbZone := zoneFromValue(float64(s.ImbalancePercent), cfg.Thresholds.ImbalanceWarningPercent, cfg.Thresholds.ImbalanceCriticalPercent)
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()
if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected {
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,
})
}
// ---------------------------------------------------------------------------
// PLC poller
// ---------------------------------------------------------------------------
func startPLCPoller(ctx context.Context) {
pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
for {
select {
case <-ctx.Done():
return
default:
}
handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot)
handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second
handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second
if err := handler.Connect(); err != nil {
markDisconnected(err.Error())
log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec)
select {
case <-ctx.Done():
return
case <-time.After(reconnectDelay):
}
continue
}
maybeLogPLCConnected()
client := gos7.NewClient(handler)
log.Println("PLC connected successfully")
buf := make([]byte, 8)
for {
select {
case <-ctx.Done():
_ = handler.Close()
return
default:
}
if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil {
log.Printf("PLC read error: %v - reconnecting...", err)
markDisconnected(err.Error())
_ = handler.Close()
break
}
var helper gos7.Helper
silaL := helper.GetRealAt(buf, 0)
silaR := helper.GetRealAt(buf, 4)
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.Press.MaxTonnage)
imbalance := float32(math.Abs(float64(silaL - silaR)))
bias := silaL - silaR
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
state.ImbalancePercent = imbalance
state.BiasPercent = bias
state.LastUpdate = now
state.Unlock()
sample := Sample{
TS: now,
SilaLPct: silaL,
SilaRPct: silaR,
SilaLKN: leftKN,
SilaRKN: rightKN,
SumPercent: sumPercent,
SumKN: sumKN,
ImbalancePercent: imbalance,
BiasPercent: bias,
}
evaluateProcessAlarms(sample)
enqueueSample(sample)
select {
case <-ctx.Done():
_ = handler.Close()
return
case <-time.After(pollInterval):
}
}
}
}
// ---------------------------------------------------------------------------
// Query helpers
// ---------------------------------------------------------------------------
func parseWindow(raw string) (time.Duration, string, error) {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
s = fmt.Sprintf("%dm", cfg.Trend.Minutes)
}
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
}
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")
}
func queryHistory(window time.Duration) ([]HistoryPoint, error) {
cutoffNs := time.Now().Add(-window).UTC().UnixNano()
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)
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{
Time: formatHistoryLabel(t, window),
SilaL: float32(l),
SilaR: float32(r),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(points) <= cfg.DB.MaxChartPoints {
return points, nil
}
return downsamplePoints(points, cfg.DB.MaxChartPoints), nil
}
func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint {
if len(points) <= max || max < 3 {
return points
}
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])
}
if len(out) == 0 {
return points
}
out[len(out)-1] = points[len(points)-1]
return out
}
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
}
// ---------------------------------------------------------------------------
// Trend / stability classification
// ---------------------------------------------------------------------------
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
if oldCount < 3 || newCount < 3 {
return "insufficient_data"
}
if math.Abs(delta) < stableThreshold {
return "stable"
}
if delta > 0 {
return posLabel
}
return negLabel
}
func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) {
if sampleCount < 8 {
return "insufficient_data", "Too few samples in selected trend window"
}
if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
if avgImb5m >= cfg.Thresholds.ImbalanceCriticalPercent {
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"
}
if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
if avgImb5m >= cfg.Thresholds.ImbalanceWarningPercent {
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
forceDirection := classifyDirection(forceDelta, forceOld.Count, forceNew.Count, 1.0, "rising", "falling")
imbDirection := classifyDirection(imbDelta, imbOld.Count, imbNew.Count, 0.5, "worsening", "improving")
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() {
var ts, severity, source, state, message string
var value, limitValue float64
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
}
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
func apiData(w http.ResponseWriter, r *http.Request) {
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
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(HistoryResponse{
Window: label,
Points: points,
})
}
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,
})
}
func serveUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(cachedUI)
}
func initCachedUI() {
data := PageData{
Title: cfg.UI.Title,
Subtitle: cfg.UI.Subtitle,
LeftLabel: cfg.UI.LeftLabel,
RightLabel: cfg.UI.RightLabel,
UnitForce: cfg.UI.UnitForce,
UnitPct: cfg.UI.UnitPct,
MaxTonnage: cfg.Press.MaxTonnage,
WarningPercent: cfg.Thresholds.WarningPercent,
CriticalPercent: cfg.Thresholds.CriticalPercent,
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent,
ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent,
PollMs: cfg.PLC.PollMs,
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
DefaultTrendWindow: "15m",
ShowHeaderControls: boolValue(cfg.Modules.ShowHeaderControls, true),
ShowVerdict: boolValue(cfg.Modules.ShowVerdict, true),
ShowSummaryBar: boolValue(cfg.Modules.ShowSummaryBar, true),
ShowOverview: boolValue(cfg.Modules.ShowOverview, true),
ShowIntelligence: boolValue(cfg.Modules.ShowIntelligence, true),
ShowAlarmTimeline: boolValue(cfg.Modules.ShowAlarmTimeline, true),
ShowGauges: boolValue(cfg.Modules.ShowGauges, true),
ShowTrendChart: boolValue(cfg.Modules.ShowTrendChart, true),
}
var buf bytes.Buffer
if err := uiTemplate.Execute(&buf, data); err != nil {
log.Fatalf("failed to pre-render UI template: %v", err)
}
cachedUI = buf.Bytes()
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func main() {
wd, err := os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
configPath := filepath.Join(wd, "config.yaml")
cfg, err = loadOrCreateConfig(configPath)
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
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)
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize)
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",
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)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
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) }()
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.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
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")
}
const uiHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<script src="/static/tailwind.min.js"></script>
<script src="/static/chart.umd.min.js"></script>
<style>
:root {
--bg1: #050816;
--bg2: #0b1224;
--panel: rgba(255,255,255,0.055);
--body-text: #f4f4f5;
--button-bg: rgba(255,255,255,0.05);
--button-border: rgba(255,255,255,0.10);
--button-text: #e4e4e7;
}
* { box-sizing: border-box; }
html, body {
min-height: 100%;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background:
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%);
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%);
}
.glass {
background: var(--panel);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
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);
}
.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); }
.gauge-container {
position: relative;
width: 100%;
max-width: 500px;
height: 360px;
margin: 0 auto;
}
.gauge-canvas {
width: 100%;
height: 100%;
display: block;
}
.window-btn.active,
.trend-window-btn.active {
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;
}
body[data-theme="light"] .window-btn.active,
body[data-theme="light"] .trend-window-btn.active {
color: #0f172a;
background: rgba(14,165,233,0.12);
}
.chart-wrap {
width: min(92vw, 1800px);
margin: 0 auto;
}
.summary-card,
.intel-card,
.verdict-card {
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;
}
.intel-card {
min-height: 126px;
align-items: flex-start;
}
.summary-card.ok,
.intel-card.ok,
.verdict-card.ok {
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);
}
.summary-card.warning,
.intel-card.warning,
.verdict-card.warning {
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);
}
.summary-card.critical,
.intel-card.critical,
.verdict-card.critical {
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);
}
.summary-card.neutral,
.intel-card.neutral,
.verdict-card.neutral {
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; }
.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;
}
</style>
</head>
<body data-theme="dark">
<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">
<div class="flex items-center gap-3">
<span class="text-2xl">&#9888;&#65039;</span>
<span id="alarm-text">CRITICAL ALARM ACTIVE</span>
</div>
</div>
<div class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between mb-8">
<div>
<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>
<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>
</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">
<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>
</div>
</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>
<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>
{{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">
<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>
{{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>
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
<div class="mt-2 flex items-end gap-4">
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
<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">TOTAL %</div>
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
</div>
<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">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>
</div>
<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>
<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">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>
</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="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>
{{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>
<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>
{{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">
<div class="flex items-center gap-4">
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<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>
<div id="digital-l" class="text-right">
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
<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>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeL" class="gauge-canvas"></canvas>
</div>
</div>
<div id="card-r" 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 items-center gap-4">
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<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>
<div id="digital-r" class="text-right">
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
<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>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeR" class="gauge-canvas"></canvas>
</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">
<div>
<h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
<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>
</div>
<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>
<div class="h-[52vh] min-h-[420px] max-h-[760px]">
<canvas id="lineChart"></canvas>
</div>
</div>
</div>
{{end}}
</div>
<script>
const WARNING_PERCENT = {{.WarningPercent}};
const CRITICAL_PERCENT = {{.CriticalPercent}};
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}};
const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}};
const UNIT_FORCE = '{{.UnitForce}}';
const UNIT_PCT = '{{.UnitPct}}';
const POLL_MS = {{.PollMs}};
const DEFAULT_WINDOW = '{{.DefaultWindow}}';
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;
let lineChart = null;
let latestData = null;
let currentWindow = DEFAULT_WINDOW;
let currentTrendWindow = DEFAULT_TREND_WINDOW;
let currentTheme = 'dark';
let historyBusy = false;
let trendBusy = false;
let alarmsBusy = false;
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function isLightTheme() {
return currentTheme === 'light';
}
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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)),
g: Math.round(lerp(c1.g, c2.g, t)),
b: Math.round(lerp(c1.b, c2.b, t))
};
}
function colorToCss(c, a) {
a = a === undefined ? 1 : a;
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 };
}
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));
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
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 };
}
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);
}
function drawArc(ctx, cx, cy, r, a1, a2, stroke, width, shadowBlur) {
shadowBlur = shadowBlur || 0;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, a1, a2, false);
ctx.strokeStyle = stroke;
ctx.lineWidth = width;
ctx.lineCap = 'butt';
if (shadowBlur > 0) {
ctx.shadowColor = stroke;
ctx.shadowBlur = shadowBlur;
}
ctx.stroke();
ctx.restore();
}
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);
}
}
function drawGauge(canvasId, percentValue, knValue, sideAccent) {
if (!SHOW_GAUGES) return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const prep = prepCanvas(canvas);
const ctx = prep.ctx;
const w = prep.w;
const h = prep.h;
const light = isLightTheme();
const cx = w / 2;
const cy = h * 0.57;
const radius = Math.min(w, h) * 0.34;
const trackWidth = Math.max(18, radius * 0.16);
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
const valueAngle = valueToAngle(value);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius + 22, 0, Math.PI * 2);
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)';
ctx.shadowBlur = 30;
ctx.fill();
ctx.restore();
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);
drawColoredBand(ctx, cx, cy, radius, trackWidth);
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);
for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) {
const a = valueToAngle(v);
const isMajor = Math.abs(v % 10) < 0.0001;
const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001;
const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80;
const r2 = radius * 0.97;
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);
if (isThreshold) {
ctx.strokeStyle = light ? '#0f172a' : '#ffffff';
ctx.lineWidth = 3.2;
} else if (isMajor) {
ctx.strokeStyle = light ? 'rgba(15,23,42,0.80)' : 'rgba(255,255,255,0.86)';
ctx.lineWidth = 2.2;
} else {
ctx.strokeStyle = light ? 'rgba(71,85,105,0.65)' : 'rgba(161,161,170,0.74)';
ctx.lineWidth = 1.1;
}
ctx.stroke();
}
const labels = [0, 20, 40, 60, 80, 100, 120, 130];
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = light ? 'rgba(15,23,42,0.88)' : 'rgba(244,244,245,0.96)';
ctx.font = '700 18px system-ui, sans-serif';
for (const v of labels) {
const a = valueToAngle(v);
const p = polar(cx, cy, radius * 1.13, a);
ctx.fillText(String(v), p.x, p.y);
}
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);
ctx.save();
ctx.beginPath();
ctx.moveTo(left.x, left.y);
ctx.lineTo(tip.x, tip.y);
ctx.lineTo(right.x, right.y);
ctx.lineTo(tail.x, tail.y);
ctx.closePath();
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.18)';
ctx.shadowBlur = 10;
ctx.fill();
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
ctx.fillStyle = light ? '#ffffff' : '#101114';
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = sideAccent;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
ctx.fill();
const majorTickInner = radius * 0.72;
const centerPlateRadius = majorTickInner - 18;
ctx.beginPath();
ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2);
ctx.fillStyle = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.90)';
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.10)';
ctx.stroke();
const valueText = value.toFixed(1);
let valueFontPx = 52;
if (value >= 100) valueFontPx = 46;
if (w < 420) valueFontPx -= 4;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = light ? '#0f172a' : '#ffffff';
ctx.font = '700 ' + valueFontPx + 'px system-ui, sans-serif';
ctx.fillText(valueText, cx, cy - 6);
ctx.fillStyle = sideAccent;
ctx.font = '700 18px system-ui, sans-serif';
ctx.fillText(UNIT_PCT, cx, cy + 28);
ctx.fillStyle = light ? '#334155' : '#a1a1aa';
ctx.font = '600 16px system-ui, sans-serif';
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 54);
}
function getZone(percentValue) {
if (percentValue >= CRITICAL_PERCENT) return 'critical';
if (percentValue >= WARNING_PERCENT) return 'warning';
return 'ok';
}
function getImbalanceZone(value) {
if (value >= IMBALANCE_CRITICAL_PERCENT) return 'critical';
if (value >= IMBALANCE_WARNING_PERCENT) return 'warning';
return 'ok';
}
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';
text.textContent = 'Disconnected';
text.className = 'font-semibold text-lg text-red-400';
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;
}
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';
}
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');
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: --:--:--.---';
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');
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
}
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;
status.className = 'summary-status ' + zone + ' font-semibold mt-1';
status.textContent = text;
val.textContent = value;
}
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', '--');
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);
if (stale) {
setSummaryCard('plc', 'warning', 'STALE', 'No fresh data');
} else {
setSummaryCard('plc', 'ok', 'OK', 'Online');
}
}
function updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance) {
if (!SHOW_VERDICT) return;
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');
}
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';
banner.classList.remove('hidden');
return;
}
if (stale) {
text.textContent = 'WARNING • PLC DATA STALE';
banner.classList.remove('hidden');
return;
}
const leftCritical = leftPercent >= CRITICAL_PERCENT;
const rightCritical = rightPercent >= CRITICAL_PERCENT;
const imbalanceCritical = imbalancePercent >= IMBALANCE_CRITICAL_PERCENT;
if (!leftCritical && !rightCritical && !imbalanceCritical) {
banner.classList.add('hidden');
return;
}
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');
}
text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • ');
banner.classList.remove('hidden');
}
function redrawGauges() {
if (!SHOW_GAUGES) return;
if (!latestData) return;
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;
drawGauge('gaugeL', leftPercent, leftKN, '#22d3ee');
drawGauge('gaugeR', rightPercent, rightKN, '#c084fc');
}
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) {
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;
}
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() {
if (!SHOW_TREND_CHART || !lineChart) return;
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');
}
async function fetchLiveData() {
try {
const res = await fetch('/api/data', { cache: 'no-store' });
const d = await res.json();
latestData = d;
const connected = !!d.connected;
const leftPercent = Number(d.sila_l) || 0;
const rightPercent = Number(d.sila_r) || 0;
const leftKN = Number(d.sila_l_kn) || 0;
const rightKN = Number(d.sila_r_kn) || 0;
const sumPercent = Number(d.sum_percent) || 0;
const sumKN = Number(d.sum_kn) || 0;
const imbalance = Number(d.imbalance_percent) || 0;
const bias = Number(d.bias_percent) || 0;
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);
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);
}
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));
}
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);
updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance);
updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance);
updateAlarmBanner(leftPercent, rightPercent, imbalance, connected, stale);
redrawGauges();
} catch (err) {
console.warn('Live fetch error:', err);
setConnectionIndicator(false, false);
updateSummaryBar(false, false, 0, 0, 0);
updateMachineVerdict(false, false, 0, 0, 0);
updateAlarmBanner(0, 0, 0, false, false);
}
}
async function fetchHistory() {
if (!SHOW_TREND_CHART || !lineChart) return;
if (historyBusy) return;
historyBusy = true;
try {
const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' });
if (!res.ok) throw new Error('History request failed');
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);
const warnLine = labels.map(() => WARNING_PERCENT);
const critLine = labels.map(() => CRITICAL_PERCENT);
lineChart.data.labels = labels;
lineChart.data.datasets[0].data = dataL;
lineChart.data.datasets[1].data = dataR;
lineChart.data.datasets[2].data = warnLine;
lineChart.data.datasets[3].data = critLine;
lineChart.update('none');
} catch (err) {
console.warn('History fetch error:', err);
} finally {
historyBusy = false;
}
}
async function fetchTrend() {
if (!SHOW_INTELLIGENCE) return;
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() {
if (!SHOW_ALARM_TIMELINE) return;
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');
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>';
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;
}
}
function setActiveWindowButton(value) {
document.querySelectorAll('.window-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.window === value);
});
}
function setActiveTrendWindowButton(value) {
document.querySelectorAll('.trend-window-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.window === value);
});
}
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();
}
window.onload = () => {
initTheme();
setActiveWindowButton(DEFAULT_WINDOW);
setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);
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));
});
document.querySelectorAll('.trend-window-btn').forEach(btn => {
btn.addEventListener('click', () => useTrendWindow(btn.dataset.window));
});
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();
});
}
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();
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: []
}
]
},
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();
}
}
fetchLiveData();
fetchHistory();
fetchTrend();
fetchAlarms();
setInterval(fetchLiveData, POLL_MS);
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);
};
</script>
</body>
</html>`