Tonnage-app-IMCO/main.go
2026-04-16 19:12:15 +02:00

1471 lines
50 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/robinson/gos7"
"gopkg.in/yaml.v3"
)
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"`
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"`
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 {
MAX_TONNAGE 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 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"`
CheckpointPages int `yaml:"checkpoint_pages"`
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
}
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,
},
Trend: TrendConfig{Minutes: 5},
Press: PressConfig{MAX_TONNAGE: 64},
UI: UIConfig{
Title: "Force Monitor v0.2.0 - pre-alpha",
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: "%",
},
DB: DBConfig{
Path: "force_monitor.db",
BusyTimeoutMs: 5000,
BatchSize: 32,
FlushIntervalMs: 1000,
RetentionDays: 30,
MaxChartPoints: 2000,
WriterQueueSize: 4096,
CheckpointPages: 1000,
CleanupIntervalHr: 6,
},
}
}
func normalizeConfig(cfg *Config) {
def := defaultConfig()
if strings.TrimSpace(cfg.Server.ListenAddr) == "" {
cfg.Server.ListenAddr = def.Server.ListenAddr
}
if strings.TrimSpace(cfg.PLC.IP) == "" {
cfg.PLC.IP = def.PLC.IP
}
if cfg.PLC.DBNum <= 0 {
cfg.PLC.DBNum = def.PLC.DBNum
}
if cfg.PLC.PollMs <= 0 {
cfg.PLC.PollMs = def.PLC.PollMs
}
if cfg.PLC.ConnectTimeoutSec <= 0 {
cfg.PLC.ConnectTimeoutSec = def.PLC.ConnectTimeoutSec
}
if cfg.PLC.IdleTimeoutSec <= 0 {
cfg.PLC.IdleTimeoutSec = def.PLC.IdleTimeoutSec
}
if cfg.PLC.ReconnectDelaySec <= 0 {
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
}
if cfg.Thresholds.WarningPercent <= 0 {
cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent
}
if cfg.Thresholds.CriticalPercent <= 0 {
cfg.Thresholds.CriticalPercent = def.Thresholds.CriticalPercent
}
if cfg.Thresholds.GaugeMaxPercent <= 0 {
cfg.Thresholds.GaugeMaxPercent = def.Thresholds.GaugeMaxPercent
}
if cfg.Trend.Minutes <= 0 {
cfg.Trend.Minutes = def.Trend.Minutes
}
if cfg.Press.MAX_TONNAGE <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
cfg.Press.MAX_TONNAGE = cfg.Press.LegacyMaxTonnage
}
if cfg.Press.MAX_TONNAGE <= 0 {
cfg.Press.MAX_TONNAGE = def.Press.MAX_TONNAGE
}
if strings.TrimSpace(cfg.UI.Title) == "" {
cfg.UI.Title = def.UI.Title
}
if strings.TrimSpace(cfg.UI.Subtitle) == "" {
cfg.UI.Subtitle = def.UI.Subtitle
}
if strings.TrimSpace(cfg.UI.LeftLabel) == "" {
cfg.UI.LeftLabel = def.UI.LeftLabel
}
if strings.TrimSpace(cfg.UI.RightLabel) == "" {
cfg.UI.RightLabel = def.UI.RightLabel
}
if strings.TrimSpace(cfg.UI.UnitForce) == "" {
cfg.UI.UnitForce = def.UI.UnitForce
}
if strings.TrimSpace(cfg.UI.UnitPct) == "" {
cfg.UI.UnitPct = def.UI.UnitPct
}
if strings.TrimSpace(cfg.DB.Path) == "" {
cfg.DB.Path = def.DB.Path
}
if cfg.DB.BusyTimeoutMs <= 0 {
cfg.DB.BusyTimeoutMs = def.DB.BusyTimeoutMs
}
if cfg.DB.BatchSize <= 0 {
cfg.DB.BatchSize = def.DB.BatchSize
}
if cfg.DB.FlushIntervalMs <= 0 {
cfg.DB.FlushIntervalMs = def.DB.FlushIntervalMs
}
if cfg.DB.RetentionDays <= 0 {
cfg.DB.RetentionDays = def.DB.RetentionDays
}
if cfg.DB.MaxChartPoints <= 0 {
cfg.DB.MaxChartPoints = def.DB.MaxChartPoints
}
if cfg.DB.WriterQueueSize <= 0 {
cfg.DB.WriterQueueSize = def.DB.WriterQueueSize
}
if cfg.DB.CheckpointPages <= 0 {
cfg.DB.CheckpointPages = def.DB.CheckpointPages
}
if cfg.DB.CleanupIntervalHr <= 0 {
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
}
type Sample struct {
TS time.Time
SilaLPct float32
SilaRPct float32
SilaLKN float32
SilaRKN float32
SumPercent float32
SumKN float32
ImbalancePercent float32
BiasPercent float32
}
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
}
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"`
}
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 PageData struct {
Title string
Subtitle string
LeftLabel string
RightLabel string
UnitForce string
UnitPct string
MaxTonnage float64
WarningPercent float64
CriticalPercent float64
GaugeMaxPercent float64
PollMs int
DefaultWindow string
}
var (
cfg Config
state AppState
db *sql.DB
sampleCh chan Sample
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
)
func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) {
lp := float64(leftPercent)
rp := float64(rightPercent)
sumPct := (lp + rp) / 2.0
left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE
return float32(left), float32(right), float32(sumPct), float32(total)
}
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,
}
}
func setDisconnected() {
state.Lock()
state.Connected = false
state.Unlock()
}
func enqueueSample(s Sample) {
select {
case sampleCh <- s:
default:
state.Lock()
state.DroppedSamples++
state.Unlock()
}
}
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,
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,
bias_pct REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
`
if _, err := database.Exec(schema); err != nil {
_ = database.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
return database, nil
}
func startDBWriter(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, 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.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 s := <-sampleCh:
batch = append(batch, s)
if len(batch) >= cfg.DB.BatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func startDBCleanup(database *sql.DB) {
if cfg.DB.RetentionDays <= 0 {
return
}
ticker := time.NewTicker(time.Duration(cfg.DB.CleanupIntervalHr) * time.Hour)
defer ticker.Stop()
cleanup := func() {
cutoff := time.Now().AddDate(0, 0, -cfg.DB.RetentionDays).UTC().Format(time.RFC3339Nano)
if _, err := database.Exec(`DELETE FROM samples WHERE ts < ?`, cutoff); err != nil {
log.Printf("db cleanup failed: %v", err)
}
}
cleanup()
for range ticker.C {
cleanup()
}
}
func startPLCPoller() {
for {
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 {
setDisconnected()
log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec)
time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second)
continue
}
client := gos7.NewClient(handler)
log.Println("PLC connected successfully")
for {
buf := make([]byte, 8)
if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil {
log.Printf("PLC read error: %v - reconnecting...", err)
setDisconnected()
_ = handler.Close()
break
}
var helper gos7.Helper
silaL := helper.GetRealAt(buf, 0)
silaR := helper.GetRealAt(buf, 4)
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR)
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()
enqueueSample(Sample{
TS: now,
SilaLPct: silaL,
SilaRPct: silaR,
SilaLKN: leftKN,
SilaRKN: rightKN,
SumPercent: sumPercent,
SumKN: sumKN,
ImbalancePercent: imbalance,
BiasPercent: bias,
})
time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond)
}
}
}
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) {
cutoff := time.Now().Add(-window).UTC().Format(time.RFC3339Nano)
rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts >= ? ORDER BY ts ASC`, cutoff)
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 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 serveUI(w http.ResponseWriter, r *http.Request) {
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.MAX_TONNAGE,
WarningPercent: cfg.Thresholds.WarningPercent,
CriticalPercent: cfg.Thresholds.CriticalPercent,
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
PollMs: cfg.PLC.PollMs,
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := uiTemplate.Execute(w, data); err != nil {
log.Printf("template execute error: %v", err)
http.Error(w, "render failed", http.StatusInternalServerError)
}
}
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)
log.Printf("config loaded from: %s", configPath)
log.Printf("sqlite 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.MAX_TONNAGE, cfg.UI.UnitForce)
go startDBWriter(db)
go startDBCleanup(db)
go startPLCPoller()
http.HandleFunc("/", serveUI)
http.HandleFunc("/api/data", apiData)
http.HandleFunc("/api/history", apiHistory)
log.Println("S7-1200 Force Monitor started")
log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr)
log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil))
}
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="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
--bg1: #050816;
--bg2: #0b1224;
--panel: rgba(255,255,255,0.055);
}
* { box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, 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: #f4f4f5;
}
.title { font-family: 'Space Grotesk', sans-serif; }
.glass {
background: var(--panel);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.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: 390px;
margin: 0 auto;
}
.gauge-canvas {
width: 100%;
height: 100%;
display: block;
}
.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;
}
.chart-wrap {
width: min(92vw, 1800px);
margin: 0 auto;
}
</style>
</head>
<body>
<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">⚠️</span>
<span id="alarm-text">CRITICAL FORCE ALARM ACTIVE</span>
</div>
</div>
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between mb-8">
<div>
<h1 class="title 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="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 queue: <span id="dropped-samples">0</span></div>
</div>
</div>
<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">DB</div>
<div class="text-sm font-mono text-zinc-300 mt-2">SQLite WAL</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">non-blocking writer</div>
</div>
</div>
</div>
</div>
<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>
<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</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>
</div>
<script>
const WARNING_PERCENT = {{.WarningPercent}};
const CRITICAL_PERCENT = {{.CriticalPercent}};
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
const UNIT_FORCE = '{{.UnitForce}}';
const UNIT_PCT = '{{.UnitPct}}';
const POLL_MS = {{.PollMs}};
const DEFAULT_WINDOW = '{{.DefaultWindow}}';
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 historyBusy = 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 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 = 1) {
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 = 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) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const { ctx, w, h } = prepCanvas(canvas);
const cx = w / 2;
const cy = h * 0.55;
const radius = Math.min(w, h) * 0.37;
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 + 24, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.015)';
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 30;
ctx.fill();
ctx.restore();
drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, 'rgba(255,255,255,0.06)', trackWidth + 10, 0);
drawColoredBand(ctx, cx, cy, radius, trackWidth);
drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, 'rgba(9,9,11,0.60)', trackWidth - 1, 0);
drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, '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 = '#ffffff';
ctx.lineWidth = 3.2;
} else if (isMajor) {
ctx.strokeStyle = 'rgba(255,255,255,0.86)';
ctx.lineWidth = 2.2;
} else {
ctx.strokeStyle = '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 = 'rgba(244,244,245,0.96)';
ctx.font = '700 18px Inter, 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 = '#ffffff';
ctx.shadowColor = '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 = '#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 = '#ffffff';
ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy + 8, radius * 0.36, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(9,9,11,0.85)';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffffff';
ctx.font = '700 48px Space Grotesk, Inter, sans-serif';
ctx.fillText(value.toFixed(1), cx, cy - 8);
ctx.fillStyle = sideAccent;
ctx.font = '700 18px Inter, sans-serif';
ctx.fillText(UNIT_PCT, cx, cy + 26);
ctx.fillStyle = '#a1a1aa';
ctx.font = '600 16px Inter, sans-serif';
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 52);
}
function getZone(percentValue) {
if (percentValue >= CRITICAL_PERCENT) return 'critical';
if (percentValue >= WARNING_PERCENT) return 'warning';
return 'normal';
}
function setStatusConnected(connected) {
const dot = document.getElementById('dot');
const text = document.getElementById('status-text');
if (connected) {
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';
} else {
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';
}
}
function applyChannelState(side, percentValue) {
const zone = getZone(percentValue);
const card = document.getElementById('card-' + side);
const led = document.getElementById('led-' + side);
const stateText = document.getElementById('state-' + side);
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 updateAlarmBanner(leftPercent, rightPercent) {
const banner = document.getElementById('alarm-banner');
const text = document.getElementById('alarm-text');
const leftCritical = leftPercent >= CRITICAL_PERCENT;
const rightCritical = rightPercent >= CRITICAL_PERCENT;
if (leftCritical || rightCritical) {
if (leftCritical && rightCritical) {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT + RIGHT';
} else if (leftCritical) {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT';
} else {
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • RIGHT';
}
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
function redrawGauges() {
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');
}
async function fetchLiveData() {
try {
const res = await fetch('/api/data', { cache: 'no-store' });
const d = await res.json();
latestData = d;
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;
setStatusConnected(!!d.connected);
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1);
document.getElementById('bias-pct').textContent = bias.toFixed(1);
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
document.getElementById('dropped-samples').textContent = String(d.dropped_samples || 0);
applyChannelState('l', leftPercent);
applyChannelState('r', rightPercent);
updateAlarmBanner(leftPercent, rightPercent);
redrawGauges();
} catch (err) {
console.warn('Live fetch error:', err);
setStatusConnected(false);
}
}
async function fetchHistory() {
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);
lineChart.data.labels = labels;
lineChart.data.datasets[0].data = dataL;
lineChart.data.datasets[1].data = dataR;
lineChart.update('none');
} catch (err) {
console.warn('History fetch error:', err);
} finally {
historyBusy = false;
}
}
function setActiveWindowButton(value) {
document.querySelectorAll('.window-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.window === value);
});
}
function useWindow(value) {
currentWindow = value;
setActiveWindowButton(value);
fetchHistory();
}
window.onload = () => {
setActiveWindowButton(DEFAULT_WINDOW);
document.querySelectorAll('.window-btn').forEach(btn => {
btn.addEventListener('click', () => useWindow(btn.dataset.window));
});
document.getElementById('apply-window').addEventListener('click', () => {
const val = document.getElementById('custom-window').value.trim();
if (!val) return;
currentWindow = val;
document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
fetchHistory();
});
lineChart = new Chart(document.getElementById('lineChart'), {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Levi peak %',
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 0,
data: []
},
{
label: 'Desni peak %',
borderColor: '#c084fc',
backgroundColor: 'rgba(192,132,252,0.10)',
borderWidth: 3,
tension: 0.22,
pointRadius: 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)' }
}
}
});
fetchLiveData();
fetchHistory();
setInterval(fetchLiveData, POLL_MS);
setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
window.addEventListener('resize', redrawGauges);
};
</script>
</body>
</html>`