added fix for charts- nicer gauge
This commit is contained in:
parent
5ac16cd7ce
commit
bb2aeddf62
|
|
@ -11,7 +11,7 @@ plc:
|
||||||
reconnect_delay_sec: 5
|
reconnect_delay_sec: 5
|
||||||
thresholds:
|
thresholds:
|
||||||
warning_percent: 80
|
warning_percent: 80
|
||||||
critical_percent: 95
|
critical_percent: 100
|
||||||
gauge_max_percent: 130
|
gauge_max_percent: 130
|
||||||
trend:
|
trend:
|
||||||
minutes: 5
|
minutes: 5
|
||||||
|
|
|
||||||
589
main.go
589
main.go
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
htmlstd "html"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -49,7 +49,7 @@ type ThresholdsConfig struct {
|
||||||
CriticalPercent float64 `yaml:"critical_percent"`
|
CriticalPercent float64 `yaml:"critical_percent"`
|
||||||
GaugeMaxPercent float64 `yaml:"gauge_max_percent"`
|
GaugeMaxPercent float64 `yaml:"gauge_max_percent"`
|
||||||
|
|
||||||
// legacy compatibility with previous config names
|
// legacy compatibility
|
||||||
LegacyWarningKn float64 `yaml:"warning_kn,omitempty"`
|
LegacyWarningKn float64 `yaml:"warning_kn,omitempty"`
|
||||||
LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
|
LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
|
||||||
LegacyMaxKn float64 `yaml:"max_kn,omitempty"`
|
LegacyMaxKn float64 `yaml:"max_kn,omitempty"`
|
||||||
|
|
@ -62,7 +62,7 @@ type TrendConfig struct {
|
||||||
type PressConfig struct {
|
type PressConfig struct {
|
||||||
MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"`
|
MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"`
|
||||||
|
|
||||||
// optional legacy compatibility
|
// legacy compatibility
|
||||||
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,8 +258,8 @@ type AppState struct {
|
||||||
|
|
||||||
type APIState struct {
|
type APIState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
SilaL float32 `json:"sila_l"` // %
|
SilaL float32 `json:"sila_l"`
|
||||||
SilaR float32 `json:"sila_r"` // %
|
SilaR float32 `json:"sila_r"`
|
||||||
SilaLkN float32 `json:"sila_l_kn"`
|
SilaLkN float32 `json:"sila_l_kn"`
|
||||||
SilaRkN float32 `json:"sila_r_kn"`
|
SilaRkN float32 `json:"sila_r_kn"`
|
||||||
SumPercent float32 `json:"sum_percent"`
|
SumPercent float32 `json:"sum_percent"`
|
||||||
|
|
@ -268,18 +268,31 @@ type APIState struct {
|
||||||
LastUpdate string `json:"last_update"`
|
LastUpdate string `json:"last_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PageData struct {
|
||||||
|
Title string
|
||||||
|
Subtitle string
|
||||||
|
LeftLabel string
|
||||||
|
RightLabel string
|
||||||
|
UnitForce string
|
||||||
|
UnitPct string
|
||||||
|
MaxTonnage float64
|
||||||
|
WarningPercent float64
|
||||||
|
CriticalPercent float64
|
||||||
|
GaugeMaxPercent float64
|
||||||
|
TrendMinutes int
|
||||||
|
MaxHistoryPoints int
|
||||||
|
PollMs int
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
state AppState
|
state AppState
|
||||||
cfg Config
|
cfg Config
|
||||||
|
uiTmpl = template.Must(template.New("ui").Parse(uiHTML))
|
||||||
)
|
)
|
||||||
|
|
||||||
// =============================================
|
// =============================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// =============================================
|
// =============================================
|
||||||
func esc(s string) string {
|
|
||||||
return htmlstd.EscapeString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDisconnected() {
|
func setDisconnected() {
|
||||||
state.Lock()
|
state.Lock()
|
||||||
state.Connected = false
|
state.Connected = false
|
||||||
|
|
@ -321,7 +334,7 @@ func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPer
|
||||||
left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
||||||
right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
||||||
|
|
||||||
// total force by your rule
|
// total force by rule
|
||||||
total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE
|
total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE
|
||||||
|
|
||||||
return float32(left), float32(right), float32(sumPct), float32(total)
|
return float32(left), float32(right), float32(sumPct), float32(total)
|
||||||
|
|
@ -359,8 +372,8 @@ func startPLCPoller() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var helper gos7.Helper
|
var helper gos7.Helper
|
||||||
silaL := helper.GetRealAt(buf, 0) // %
|
silaL := helper.GetRealAt(buf, 0)
|
||||||
silaR := helper.GetRealAt(buf, 4) // %
|
silaR := helper.GetRealAt(buf, 4)
|
||||||
|
|
||||||
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR)
|
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR)
|
||||||
|
|
||||||
|
|
@ -405,30 +418,141 @@ func apiData(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
maxHistoryPoints := getMaxHistoryPoints(cfg)
|
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,
|
||||||
|
TrendMinutes: cfg.Trend.Minutes,
|
||||||
|
MaxHistoryPoints: getMaxHistoryPoints(cfg),
|
||||||
|
PollMs: cfg.PLC.PollMs,
|
||||||
|
}
|
||||||
|
|
||||||
html := `<!DOCTYPE html>
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := uiTmpl.Execute(w, data); err != nil {
|
||||||
|
http.Error(w, "failed to render UI", http.StatusInternalServerError)
|
||||||
|
log.Printf("template execute error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("config loaded from: %s", configPath)
|
||||||
|
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 startPLCPoller()
|
||||||
|
|
||||||
|
http.HandleFunc("/", serveUI)
|
||||||
|
http.HandleFunc("/api/data", apiData)
|
||||||
|
|
||||||
|
log.Println("S7-1200 Force Monitor started")
|
||||||
|
log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr)
|
||||||
|
log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Embedded UI Template
|
||||||
|
// =============================================
|
||||||
|
const uiHTML = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>` + esc(cfg.UI.Title) + `</title>
|
<title>{{.Title}}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<style>
|
<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');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
|
||||||
.title { font-family: 'Space Grotesk', sans-serif; }
|
:root {
|
||||||
.glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(12px); }
|
--bg: #09090b;
|
||||||
.gauge-container { position: relative; width: 320px; height: 320px; margin: 0 auto; }
|
--panel: rgba(255,255,255,0.06);
|
||||||
.soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.35), 0 0 30px rgba(34,197,94,0.08); }
|
--panel-border: rgba(255,255,255,0.10);
|
||||||
.soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.35), 0 0 30px rgba(234,179,8,0.08); }
|
--muted: #a1a1aa;
|
||||||
.soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.35), 0 0 30px rgba(239,68,68,0.08); }
|
--line: #27272a;
|
||||||
.metric-big { line-height: 0.95; }
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(56,189,248,0.12), transparent 22%),
|
||||||
|
radial-gradient(circle at top right, rgba(168,85,247,0.12), transparent 22%),
|
||||||
|
linear-gradient(180deg, #09090b 0%, #0f172a 100%);
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(255,255,255,0.055);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
height: 340px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-glow-green {
|
||||||
|
box-shadow: 0 0 0 1px rgba(34,197,94,0.30), 0 0 40px rgba(34,197,94,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-glow-yellow {
|
||||||
|
box-shadow: 0 0 0 1px rgba(234,179,8,0.30), 0 0 40px rgba(234,179,8,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-glow-red {
|
||||||
|
box-shadow: 0 0 0 1px rgba(239,68,68,0.30), 0 0 40px rgba(239,68,68,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-big {
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-zinc-950 text-zinc-100">
|
<body class="text-zinc-100">
|
||||||
<div class="max-w-7xl mx-auto p-8 min-h-screen">
|
<div class="max-w-7xl mx-auto p-5 md:p-8 min-h-screen">
|
||||||
<div id="alarm-banner" class="hidden mb-8 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 id="alarm-banner" class="hidden mb-8 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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">⚠️</span>
|
<span class="text-2xl">⚠️</span>
|
||||||
|
|
@ -438,153 +562,340 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between mb-10">
|
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between mb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="title text-5xl xl:text-6xl font-semibold tracking-tighter bg-gradient-to-r from-sky-300 to-violet-300 bg-clip-text text-transparent">
|
<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">
|
||||||
` + esc(cfg.UI.Title) + `
|
{{.Title}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-zinc-400 mt-2 text-lg">` + esc(cfg.UI.Subtitle) + `</p>
|
<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 = ` + fmt.Sprintf("%.1f", cfg.Press.MAX_TONNAGE) + ` ` + esc(cfg.UI.UnitForce) + `</p>
|
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass border border-zinc-700 px-8 py-4 rounded-3xl flex items-center gap-8 w-fit">
|
<div class="status-pill px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
|
<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>
|
<span id="status-text" class="font-semibold text-lg text-red-400">Disconnected</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-8 w-px bg-zinc-700"></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 id="last-update" class="font-mono text-zinc-400 text-sm">Last update: --:--:--.---</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass border border-zinc-700 rounded-3xl p-8 mb-12">
|
<div class="glass border border-zinc-700/70 rounded-3xl p-6 md:p-8 mb-10">
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL FORCE</div>
|
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL FORCE</div>
|
||||||
<div class="mt-2 flex items-end gap-4">
|
<div class="mt-2 flex items-end gap-4">
|
||||||
<div class="text-6xl font-mono font-bold text-emerald-300 metric-big" id="sum-kn">0.0</div>
|
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 metric-big" id="sum-kn">0.0</div>
|
||||||
<div class="text-2xl text-emerald-400 mb-1">` + esc(cfg.UI.UnitForce) + `</div>
|
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 min-w-[280px]">
|
|
||||||
<div class="bg-zinc-900/60 rounded-2xl px-6 py-4 border border-zinc-800">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 min-w-[280px]">
|
||||||
|
<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-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> ` + esc(cfg.UI.UnitPct) + `</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>
|
||||||
<div class="bg-zinc-900/60 rounded-2xl px-6 py-4 border border-zinc-800">
|
<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">FORMULA</div>
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">FORMULA</div>
|
||||||
<div class="text-sm font-mono text-zinc-300 mt-2">(L + R) / 2</div>
|
<div class="text-sm font-mono text-zinc-300 mt-2">(L + R) / 2</div>
|
||||||
</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">
|
||||||
|
W {{printf "%.0f" .WarningPercent}}{{.UnitPct}} • C {{printf "%.0f" .CriticalPercent}}{{.UnitPct}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-12">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 xl:gap-10">
|
||||||
<div id="card-l" class="glass border border-zinc-700 rounded-3xl p-10 transition-all duration-300">
|
<div id="card-l" class="glass border border-zinc-700/70 rounded-3xl p-6 md:p-8 transition-all duration-300">
|
||||||
<div class="flex justify-between items-center mb-8 gap-6">
|
<div class="flex justify-between items-start mb-6 gap-6">
|
||||||
<div class="flex items-center gap-4">
|
<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 id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-3xl font-bold tracking-wider">` + esc(cfg.UI.LeftLabel) + `</h2>
|
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.LeftLabel}}</h2>
|
||||||
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="digital-l" class="text-right">
|
<div id="digital-l" class="text-right">
|
||||||
<div class="percent text-6xl font-mono font-bold text-sky-100 leading-none">0.0</div>
|
<div class="percent text-5xl md:text-6xl font-mono font-bold text-sky-100 leading-none">0.0</div>
|
||||||
<div class="text-xl text-sky-400 mt-1">` + esc(cfg.UI.UnitPct) + `</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 ` + esc(cfg.UI.UnitForce) + `</div>
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gauge-container">
|
<div class="gauge-container">
|
||||||
<canvas id="gaugeL"></canvas>
|
<canvas id="gaugeL" class="gauge-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between text-xs font-mono text-zinc-500 mt-4 px-4">
|
<div class="mt-3 grid grid-cols-4 gap-2 text-xs font-mono text-zinc-500">
|
||||||
<span>0</span>
|
<div>0</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `%</span>
|
<div class="text-center">W {{printf "%.0f" .WarningPercent}}</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `%</span>
|
<div class="text-center">C {{printf "%.0f" .CriticalPercent}}</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `%</span>
|
<div class="text-right">{{printf "%.0f" .GaugeMaxPercent}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="card-r" class="glass border border-zinc-700 rounded-3xl p-10 transition-all duration-300">
|
<div id="card-r" class="glass border border-zinc-700/70 rounded-3xl p-6 md:p-8 transition-all duration-300">
|
||||||
<div class="flex justify-between items-center mb-8 gap-6">
|
<div class="flex justify-between items-start mb-6 gap-6">
|
||||||
<div class="flex items-center gap-4">
|
<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 id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-3xl font-bold tracking-wider">` + esc(cfg.UI.RightLabel) + `</h2>
|
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.RightLabel}}</h2>
|
||||||
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="digital-r" class="text-right">
|
<div id="digital-r" class="text-right">
|
||||||
<div class="percent text-6xl font-mono font-bold text-violet-100 leading-none">0.0</div>
|
<div class="percent text-5xl md:text-6xl font-mono font-bold text-violet-100 leading-none">0.0</div>
|
||||||
<div class="text-xl text-violet-400 mt-1">` + esc(cfg.UI.UnitPct) + `</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 ` + esc(cfg.UI.UnitForce) + `</div>
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gauge-container">
|
<div class="gauge-container">
|
||||||
<canvas id="gaugeR"></canvas>
|
<canvas id="gaugeR" class="gauge-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between text-xs font-mono text-zinc-500 mt-4 px-4">
|
<div class="mt-3 grid grid-cols-4 gap-2 text-xs font-mono text-zinc-500">
|
||||||
<span>0</span>
|
<div>0</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `%</span>
|
<div class="text-center">W {{printf "%.0f" .WarningPercent}}</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `%</span>
|
<div class="text-center">C {{printf "%.0f" .CriticalPercent}}</div>
|
||||||
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `%</span>
|
<div class="text-right">{{printf "%.0f" .GaugeMaxPercent}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-12 glass border border-zinc-700 rounded-3xl p-10">
|
<div class="mt-10 glass border border-zinc-700/70 rounded-3xl p-6 md:p-8">
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-between md:items-center mb-6">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-between md:items-center mb-6">
|
||||||
<h2 class="text-3xl font-semibold">` + fmt.Sprintf("%d", cfg.Trend.Minutes) + `-Minute Live Trend</h2>
|
<h2 class="text-2xl md:text-3xl font-semibold">{{.TrendMinutes}}-Minute Live Trend</h2>
|
||||||
<div class="text-emerald-400 font-mono text-sm">Chart = percent only • FIFO • ` + fmt.Sprintf("%d", maxHistoryPoints) + ` points max • ` + fmt.Sprintf("%d", cfg.PLC.PollMs) + ` ms poll</div>
|
<div class="text-emerald-400 font-mono text-sm">
|
||||||
|
Chart = percent only • FIFO • {{.MaxHistoryPoints}} points max • {{.PollMs}} ms poll
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-96">
|
<div class="h-[360px] md:h-96">
|
||||||
<canvas id="lineChart"></canvas>
|
<canvas id="lineChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const WARNING_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.WarningPercent) + `;
|
const WARNING_PERCENT = {{.WarningPercent}};
|
||||||
const CRITICAL_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.CriticalPercent) + `;
|
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
||||||
const GAUGE_MAX_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.GaugeMaxPercent) + `;
|
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
||||||
|
const UNIT_FORCE = '{{.UnitForce}}';
|
||||||
|
const UNIT_PCT = '{{.UnitPct}}';
|
||||||
|
|
||||||
let gaugeL, gaugeR, lineChart;
|
const START_ANGLE = Math.PI * 0.75; // 135 deg
|
||||||
|
const END_ANGLE = Math.PI * 2.25; // 405 deg
|
||||||
|
|
||||||
function createGauge(ctx, activeColor) {
|
let lineChart = null;
|
||||||
return new Chart(ctx, {
|
let latestData = null;
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
function clamp(v, min, max) {
|
||||||
datasets: [{
|
return Math.max(min, Math.min(max, v));
|
||||||
data: [0, GAUGE_MAX_PERCENT],
|
|
||||||
backgroundColor: [activeColor, '#27272a'],
|
|
||||||
borderWidth: 0,
|
|
||||||
circumference: 270,
|
|
||||||
rotation: 225,
|
|
||||||
cutout: '74%'
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: { enabled: false }
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
duration: 250
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGaugeValue(gauge, percentValue, color) {
|
function polar(cx, cy, r, a) {
|
||||||
const v = Math.max(0, Math.min(GAUGE_MAX_PERCENT, Number(percentValue) || 0));
|
return {
|
||||||
gauge.data.datasets[0].data = [v, GAUGE_MAX_PERCENT - v];
|
x: cx + Math.cos(a) * r,
|
||||||
gauge.data.datasets[0].backgroundColor = [color, '#27272a'];
|
y: cy + Math.sin(a) * r
|
||||||
gauge.update('none');
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
const rw = Math.max(1, Math.floor(w * dpr));
|
||||||
|
const rh = Math.max(1, Math.floor(h * dpr));
|
||||||
|
|
||||||
|
if (canvas.width !== rw || canvas.height !== rh) {
|
||||||
|
canvas.width = rw;
|
||||||
|
canvas.height = rh;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 drawArc(ctx, cx, cy, r, a1, a2, color, width, glow) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, a1, a2, false);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = width;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
if (glow) {
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.shadowBlur = glow;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGauge(canvasId, percentValue, knValue, zone, accentColor) {
|
||||||
|
const canvas = document.getElementById(canvasId);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const setup = prepCanvas(canvas);
|
||||||
|
const ctx = setup.ctx;
|
||||||
|
const w = setup.w;
|
||||||
|
const h = setup.h;
|
||||||
|
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h * 0.60;
|
||||||
|
const radius = Math.min(w, h) * 0.34;
|
||||||
|
const bandWidth = Math.max(16, radius * 0.16);
|
||||||
|
const trackR = radius;
|
||||||
|
|
||||||
|
const clampedValue = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
|
||||||
|
const valueAngle = valueToAngle(clampedValue);
|
||||||
|
|
||||||
|
// Outer shadow ring
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius + 22, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.015)';
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
||||||
|
ctx.shadowBlur = 30;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Base track
|
||||||
|
drawArc(ctx, cx, cy, trackR, START_ANGLE, END_ANGLE, 'rgba(255,255,255,0.06)', bandWidth + 8, 0);
|
||||||
|
|
||||||
|
// Zone segments
|
||||||
|
const warnA = valueToAngle(WARNING_PERCENT);
|
||||||
|
const critA = valueToAngle(CRITICAL_PERCENT);
|
||||||
|
|
||||||
|
drawArc(ctx, cx, cy, trackR, START_ANGLE, warnA, 'rgba(34,211,238,0.22)', bandWidth, 0);
|
||||||
|
drawArc(ctx, cx, cy, trackR, warnA, critA, 'rgba(234,179,8,0.22)', bandWidth, 0);
|
||||||
|
drawArc(ctx, cx, cy, trackR, critA, END_ANGLE, 'rgba(239,68,68,0.22)', bandWidth, 0);
|
||||||
|
|
||||||
|
// Active arc
|
||||||
|
drawArc(ctx, cx, cy, trackR, START_ANGLE, valueAngle, accentColor, bandWidth - 2, 18);
|
||||||
|
|
||||||
|
// Ticks
|
||||||
|
for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.001; v += 5) {
|
||||||
|
const a = valueToAngle(v);
|
||||||
|
const isMajor = Math.abs(v % 10) < 0.001;
|
||||||
|
const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.001 || Math.abs(v - CRITICAL_PERCENT) < 0.001;
|
||||||
|
|
||||||
|
const r1 = isThreshold ? radius * 0.70 : isMajor ? radius * 0.75 : radius * 0.81;
|
||||||
|
const r2 = radius * 0.96;
|
||||||
|
|
||||||
|
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 (Math.abs(v - WARNING_PERCENT) < 0.001) {
|
||||||
|
ctx.strokeStyle = '#eab308';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
} else if (Math.abs(v - CRITICAL_PERCENT) < 0.001) {
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
} else if (isMajor) {
|
||||||
|
ctx.strokeStyle = 'rgba(228,228,231,0.82)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = 'rgba(113,113,122,0.85)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric labels
|
||||||
|
const labelValues = [];
|
||||||
|
for (let v = 0; v <= GAUGE_MAX_PERCENT; v += 20) {
|
||||||
|
labelValues.push(v);
|
||||||
|
}
|
||||||
|
if (labelValues[labelValues.length - 1] !== GAUGE_MAX_PERCENT) {
|
||||||
|
labelValues.push(GAUGE_MAX_PERCENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d4d4d8';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = '600 13px Inter, sans-serif';
|
||||||
|
|
||||||
|
for (const v of labelValues) {
|
||||||
|
const a = valueToAngle(v);
|
||||||
|
const p = polar(cx, cy, radius * 1.11, a);
|
||||||
|
ctx.fillText(String(Math.round(v)), p.x, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needle
|
||||||
|
const needleTip = polar(cx, cy, radius * 0.82, valueAngle);
|
||||||
|
const needleTail = polar(cx, cy, radius * 0.12, valueAngle + Math.PI);
|
||||||
|
const needleLeft = polar(cx, cy, 9, valueAngle + Math.PI / 2);
|
||||||
|
const needleRight = polar(cx, cy, 9, valueAngle - Math.PI / 2);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(needleLeft.x, needleLeft.y);
|
||||||
|
ctx.lineTo(needleTip.x, needleTip.y);
|
||||||
|
ctx.lineTo(needleRight.x, needleRight.y);
|
||||||
|
ctx.lineTo(needleTail.x, needleTail.y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = accentColor;
|
||||||
|
ctx.shadowColor = accentColor;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Center cap
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 16, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#18181b';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = accentColor;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#fafafa';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Inner center plate
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy + 6, radius * 0.34, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(9,9,11,0.82)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Center value text
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f4f4f5';
|
||||||
|
ctx.font = '700 34px Space Grotesk, Inter, sans-serif';
|
||||||
|
ctx.fillText(clampedValue.toFixed(1), cx, cy - 8);
|
||||||
|
|
||||||
|
ctx.fillStyle = accentColor;
|
||||||
|
ctx.font = '700 13px Inter, sans-serif';
|
||||||
|
ctx.fillText(UNIT_PCT, cx, cy + 22);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#a1a1aa';
|
||||||
|
ctx.font = '600 12px Inter, sans-serif';
|
||||||
|
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusConnected(connected) {
|
function setStatusConnected(connected) {
|
||||||
|
|
@ -608,7 +919,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
return 'normal';
|
return 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoneColor(zone, left = true) {
|
function zoneColor(zone, left) {
|
||||||
if (zone === 'critical') return '#ef4444';
|
if (zone === 'critical') return '#ef4444';
|
||||||
if (zone === 'warning') return '#eab308';
|
if (zone === 'warning') return '#eab308';
|
||||||
return left ? '#22d3ee' : '#c084fc';
|
return left ? '#22d3ee' : '#c084fc';
|
||||||
|
|
@ -674,10 +985,23 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redrawGaugesFromLatest() {
|
||||||
|
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, getZone(leftPercent), zoneColor(getZone(leftPercent), true));
|
||||||
|
drawGauge('gaugeR', rightPercent, rightKN, getZone(rightPercent), zoneColor(getZone(rightPercent), false));
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/data', { cache: 'no-store' });
|
const res = await fetch('/api/data', { cache: 'no-store' });
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
|
latestData = d;
|
||||||
|
|
||||||
const leftPercent = Number(d.sila_l) || 0;
|
const leftPercent = Number(d.sila_l) || 0;
|
||||||
const rightPercent = Number(d.sila_r) || 0;
|
const rightPercent = Number(d.sila_r) || 0;
|
||||||
|
|
@ -689,10 +1013,10 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
setStatusConnected(!!d.connected);
|
setStatusConnected(!!d.connected);
|
||||||
|
|
||||||
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
|
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
|
||||||
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ` + esc(cfg.UI.UnitForce) + `';
|
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
|
||||||
|
|
||||||
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
|
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
|
||||||
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ` + esc(cfg.UI.UnitForce) + `';
|
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE;
|
||||||
|
|
||||||
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
|
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
|
||||||
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
|
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
|
||||||
|
|
@ -700,14 +1024,13 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
applyChannelState('l', leftPercent);
|
applyChannelState('l', leftPercent);
|
||||||
applyChannelState('r', rightPercent);
|
applyChannelState('r', rightPercent);
|
||||||
|
|
||||||
setGaugeValue(gaugeL, leftPercent, zoneColor(getZone(leftPercent), true));
|
drawGauge('gaugeL', leftPercent, leftKN, getZone(leftPercent), zoneColor(getZone(leftPercent), true));
|
||||||
setGaugeValue(gaugeR, rightPercent, zoneColor(getZone(rightPercent), false));
|
drawGauge('gaugeR', rightPercent, rightKN, getZone(rightPercent), zoneColor(getZone(rightPercent), false));
|
||||||
|
|
||||||
updateAlarmBanner(leftPercent, rightPercent);
|
updateAlarmBanner(leftPercent, rightPercent);
|
||||||
|
|
||||||
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
|
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
|
||||||
|
|
||||||
if (Array.isArray(d.history) && d.history.length > 0) {
|
if (Array.isArray(d.history) && d.history.length > 0 && lineChart) {
|
||||||
const labels = d.history.map(h => h.time);
|
const labels = d.history.map(h => h.time);
|
||||||
const dataL = d.history.map(h => h.sila_l);
|
const dataL = d.history.map(h => h.sila_l);
|
||||||
const dataR = d.history.map(h => h.sila_r);
|
const dataR = d.history.map(h => h.sila_r);
|
||||||
|
|
@ -728,9 +1051,6 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
gaugeL = createGauge(document.getElementById('gaugeL'), '#22d3ee');
|
|
||||||
gaugeR = createGauge(document.getElementById('gaugeR'), '#c084fc');
|
|
||||||
|
|
||||||
lineChart = new Chart(document.getElementById('lineChart'), {
|
lineChart = new Chart(document.getElementById('lineChart'), {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -810,43 +1130,12 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
setInterval(fetchData, ` + fmt.Sprintf("%d", cfg.PLC.PollMs) + `);
|
setInterval(fetchData, {{.PollMs}});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
redrawGaugesFromLatest();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprint(w, html)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("config loaded from: %s", configPath)
|
|
||||||
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 startPLCPoller()
|
|
||||||
|
|
||||||
http.HandleFunc("/", serveUI)
|
|
||||||
http.HandleFunc("/api/data", apiData)
|
|
||||||
|
|
||||||
log.Println("S7-1200 Force Monitor started")
|
|
||||||
log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr)
|
|
||||||
log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue