Added PLC status and force data, plc data
This commit is contained in:
parent
e3ac53da44
commit
7614390b74
363
main.go
363
main.go
|
|
@ -47,9 +47,11 @@ type PLCConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThresholdsConfig struct {
|
type ThresholdsConfig struct {
|
||||||
WarningPercent float64 `yaml:"warning_percent"`
|
WarningPercent float64 `yaml:"warning_percent"`
|
||||||
CriticalPercent float64 `yaml:"critical_percent"`
|
CriticalPercent float64 `yaml:"critical_percent"`
|
||||||
GaugeMaxPercent float64 `yaml:"gauge_max_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"`
|
LegacyWarningKn float64 `yaml:"warning_kn,omitempty"`
|
||||||
LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
|
LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
|
||||||
|
|
@ -88,7 +90,9 @@ type DBConfig struct {
|
||||||
|
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Server: ServerConfig{ListenAddr: ":8080"},
|
Server: ServerConfig{
|
||||||
|
ListenAddr: ":8080",
|
||||||
|
},
|
||||||
PLC: PLCConfig{
|
PLC: PLCConfig{
|
||||||
IP: "192.168.0.1",
|
IP: "192.168.0.1",
|
||||||
DBNum: 1001,
|
DBNum: 1001,
|
||||||
|
|
@ -100,14 +104,20 @@ func defaultConfig() Config {
|
||||||
ReconnectDelaySec: 5,
|
ReconnectDelaySec: 5,
|
||||||
},
|
},
|
||||||
Thresholds: ThresholdsConfig{
|
Thresholds: ThresholdsConfig{
|
||||||
WarningPercent: 80,
|
WarningPercent: 80,
|
||||||
CriticalPercent: 95,
|
CriticalPercent: 95,
|
||||||
GaugeMaxPercent: 130,
|
GaugeMaxPercent: 130,
|
||||||
|
ImbalanceWarningPercent: 10,
|
||||||
|
ImbalanceCriticalPercent: 20,
|
||||||
|
},
|
||||||
|
Trend: TrendConfig{
|
||||||
|
Minutes: 5,
|
||||||
|
},
|
||||||
|
Press: PressConfig{
|
||||||
|
MAX_TONNAGE: 64,
|
||||||
},
|
},
|
||||||
Trend: TrendConfig{Minutes: 5},
|
|
||||||
Press: PressConfig{MAX_TONNAGE: 64},
|
|
||||||
UI: UIConfig{
|
UI: UIConfig{
|
||||||
Title: "Force Monitor v0.2.0 - pre-alpha",
|
Title: "Force Monitor",
|
||||||
Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
|
Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
|
||||||
LeftLabel: "LEVI STEBER",
|
LeftLabel: "LEVI STEBER",
|
||||||
RightLabel: "DESNI STEBER",
|
RightLabel: "DESNI STEBER",
|
||||||
|
|
@ -163,6 +173,7 @@ func normalizeConfig(cfg *Config) {
|
||||||
if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 {
|
if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 {
|
||||||
cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
|
cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Thresholds.WarningPercent <= 0 {
|
if cfg.Thresholds.WarningPercent <= 0 {
|
||||||
cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent
|
cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +183,15 @@ func normalizeConfig(cfg *Config) {
|
||||||
if cfg.Thresholds.GaugeMaxPercent <= 0 {
|
if cfg.Thresholds.GaugeMaxPercent <= 0 {
|
||||||
cfg.Thresholds.GaugeMaxPercent = def.Thresholds.GaugeMaxPercent
|
cfg.Thresholds.GaugeMaxPercent = def.Thresholds.GaugeMaxPercent
|
||||||
}
|
}
|
||||||
|
if cfg.Thresholds.ImbalanceWarningPercent <= 0 {
|
||||||
|
cfg.Thresholds.ImbalanceWarningPercent = def.Thresholds.ImbalanceWarningPercent
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.ImbalanceCriticalPercent <= 0 {
|
||||||
|
cfg.Thresholds.ImbalanceCriticalPercent = def.Thresholds.ImbalanceCriticalPercent
|
||||||
|
}
|
||||||
|
if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
|
||||||
|
cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Trend.Minutes <= 0 {
|
if cfg.Trend.Minutes <= 0 {
|
||||||
cfg.Trend.Minutes = def.Trend.Minutes
|
cfg.Trend.Minutes = def.Trend.Minutes
|
||||||
|
|
@ -316,18 +336,20 @@ type HistoryResponse struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Title string
|
Title string
|
||||||
Subtitle string
|
Subtitle string
|
||||||
LeftLabel string
|
LeftLabel string
|
||||||
RightLabel string
|
RightLabel string
|
||||||
UnitForce string
|
UnitForce string
|
||||||
UnitPct string
|
UnitPct string
|
||||||
MaxTonnage float64
|
MaxTonnage float64
|
||||||
WarningPercent float64
|
WarningPercent float64
|
||||||
CriticalPercent float64
|
CriticalPercent float64
|
||||||
GaugeMaxPercent float64
|
GaugeMaxPercent float64
|
||||||
PollMs int
|
ImbalanceWarningPercent float64
|
||||||
DefaultWindow string
|
ImbalanceCriticalPercent float64
|
||||||
|
PollMs int
|
||||||
|
DefaultWindow string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -390,6 +412,41 @@ func enqueueSample(s Sample) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 string
|
||||||
|
var ctype string
|
||||||
|
var notNull int
|
||||||
|
var dfltValue sql.NullString
|
||||||
|
var pk int
|
||||||
|
|
||||||
|
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &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) {
|
func initDatabase(dbPath string) (*sql.DB, error) {
|
||||||
dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs)
|
dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs)
|
||||||
database, err := sql.Open("sqlite3", dsn)
|
database, err := sql.Open("sqlite3", dsn)
|
||||||
|
|
@ -426,8 +483,8 @@ CREATE TABLE IF NOT EXISTS samples (
|
||||||
sila_r_kn REAL NOT NULL,
|
sila_r_kn REAL NOT NULL,
|
||||||
sum_pct REAL NOT NULL,
|
sum_pct REAL NOT NULL,
|
||||||
sum_kn REAL NOT NULL,
|
sum_kn REAL NOT NULL,
|
||||||
imbalance_pct REAL NOT NULL,
|
imbalance_pct REAL NOT NULL DEFAULT 0,
|
||||||
bias_pct REAL NOT NULL
|
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 ON samples(ts);
|
||||||
`
|
`
|
||||||
|
|
@ -436,6 +493,15 @@ CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
|
||||||
return nil, fmt.Errorf("create schema: %w", err)
|
return nil, fmt.Errorf("create schema: %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)
|
||||||
|
}
|
||||||
|
|
||||||
return database, nil
|
return database, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -664,7 +730,6 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) {
|
||||||
if len(points) <= cfg.DB.MaxChartPoints {
|
if len(points) <= cfg.DB.MaxChartPoints {
|
||||||
return points, nil
|
return points, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return downsamplePoints(points, cfg.DB.MaxChartPoints), nil
|
return downsamplePoints(points, cfg.DB.MaxChartPoints), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -726,18 +791,20 @@ func apiHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Title: cfg.UI.Title,
|
Title: cfg.UI.Title,
|
||||||
Subtitle: cfg.UI.Subtitle,
|
Subtitle: cfg.UI.Subtitle,
|
||||||
LeftLabel: cfg.UI.LeftLabel,
|
LeftLabel: cfg.UI.LeftLabel,
|
||||||
RightLabel: cfg.UI.RightLabel,
|
RightLabel: cfg.UI.RightLabel,
|
||||||
UnitForce: cfg.UI.UnitForce,
|
UnitForce: cfg.UI.UnitForce,
|
||||||
UnitPct: cfg.UI.UnitPct,
|
UnitPct: cfg.UI.UnitPct,
|
||||||
MaxTonnage: cfg.Press.MAX_TONNAGE,
|
MaxTonnage: cfg.Press.MAX_TONNAGE,
|
||||||
WarningPercent: cfg.Thresholds.WarningPercent,
|
WarningPercent: cfg.Thresholds.WarningPercent,
|
||||||
CriticalPercent: cfg.Thresholds.CriticalPercent,
|
CriticalPercent: cfg.Thresholds.CriticalPercent,
|
||||||
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
|
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
|
||||||
PollMs: cfg.PLC.PollMs,
|
ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent,
|
||||||
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent,
|
||||||
|
PollMs: cfg.PLC.PollMs,
|
||||||
|
DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
@ -835,7 +902,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
height: 390px;
|
height: 360px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -856,6 +923,69 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
width: min(92vw, 1800px);
|
width: min(92vw, 1800px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-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 {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -863,7 +993,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
<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 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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">⚠️</span>
|
<span class="text-2xl">⚠️</span>
|
||||||
<span id="alarm-text">CRITICAL FORCE ALARM ACTIVE</span>
|
<span id="alarm-text">CRITICAL ALARM ACTIVE</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -886,6 +1016,41 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
|
||||||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -915,9 +1080,9 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-zinc-900/60 rounded-2xl px-5 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">DB</div>
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
|
||||||
<div class="text-sm font-mono text-zinc-300 mt-2">SQLite WAL</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">non-blocking writer</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1003,6 +1168,8 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const WARNING_PERCENT = {{.WarningPercent}};
|
const WARNING_PERCENT = {{.WarningPercent}};
|
||||||
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
const CRITICAL_PERCENT = {{.CriticalPercent}};
|
||||||
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
|
||||||
|
const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}};
|
||||||
|
const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}};
|
||||||
const UNIT_FORCE = '{{.UnitForce}}';
|
const UNIT_FORCE = '{{.UnitForce}}';
|
||||||
const UNIT_PCT = '{{.UnitPct}}';
|
const UNIT_PCT = '{{.UnitPct}}';
|
||||||
const POLL_MS = {{.PollMs}};
|
const POLL_MS = {{.PollMs}};
|
||||||
|
|
@ -1107,15 +1274,15 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const { ctx, w, h } = prepCanvas(canvas);
|
const { ctx, w, h } = prepCanvas(canvas);
|
||||||
|
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
const cy = h * 0.55;
|
const cy = h * 0.57;
|
||||||
const radius = Math.min(w, h) * 0.37;
|
const radius = Math.min(w, h) * 0.34;
|
||||||
const trackWidth = Math.max(18, radius * 0.16);
|
const trackWidth = Math.max(18, radius * 0.16);
|
||||||
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
|
const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
|
||||||
const valueAngle = valueToAngle(value);
|
const valueAngle = valueToAngle(value);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2);
|
ctx.arc(cx, cy, radius + 22, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.015)';
|
ctx.fillStyle = 'rgba(255,255,255,0.015)';
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.45)';
|
ctx.shadowColor = 'rgba(0,0,0,0.45)';
|
||||||
ctx.shadowBlur = 30;
|
ctx.shadowBlur = 30;
|
||||||
|
|
@ -1199,34 +1366,47 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
const majorTickInner = radius * 0.72;
|
||||||
|
const centerPlateRadius = majorTickInner - 18;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy + 8, radius * 0.36, 0, Math.PI * 2);
|
ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = 'rgba(9,9,11,0.85)';
|
ctx.fillStyle = 'rgba(9,9,11,0.90)';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1.2;
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
const valueText = value.toFixed(1);
|
||||||
|
let valueFontPx = 52;
|
||||||
|
if (value >= 100) valueFontPx = 46;
|
||||||
|
if (w < 420) valueFontPx -= 4;
|
||||||
|
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.font = '700 48px Space Grotesk, Inter, sans-serif';
|
ctx.font = '700 ' + valueFontPx + 'px Space Grotesk, Inter, sans-serif';
|
||||||
ctx.fillText(value.toFixed(1), cx, cy - 8);
|
ctx.fillText(valueText, cx, cy - 6);
|
||||||
|
|
||||||
ctx.fillStyle = sideAccent;
|
ctx.fillStyle = sideAccent;
|
||||||
ctx.font = '700 18px Inter, sans-serif';
|
ctx.font = '700 18px Inter, sans-serif';
|
||||||
ctx.fillText(UNIT_PCT, cx, cy + 26);
|
ctx.fillText(UNIT_PCT, cx, cy + 28);
|
||||||
|
|
||||||
ctx.fillStyle = '#a1a1aa';
|
ctx.fillStyle = '#a1a1aa';
|
||||||
ctx.font = '600 16px Inter, sans-serif';
|
ctx.font = '600 16px Inter, sans-serif';
|
||||||
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 52);
|
ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 54);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZone(percentValue) {
|
function getZone(percentValue) {
|
||||||
if (percentValue >= CRITICAL_PERCENT) return 'critical';
|
if (percentValue >= CRITICAL_PERCENT) return 'critical';
|
||||||
if (percentValue >= WARNING_PERCENT) return 'warning';
|
if (percentValue >= WARNING_PERCENT) return 'warning';
|
||||||
return 'normal';
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImbalanceZone(value) {
|
||||||
|
if (value >= IMBALANCE_CRITICAL_PERCENT) return 'critical';
|
||||||
|
if (value >= IMBALANCE_WARNING_PERCENT) return 'warning';
|
||||||
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusConnected(connected) {
|
function setStatusConnected(connected) {
|
||||||
|
|
@ -1283,25 +1463,73 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
|
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAlarmBanner(leftPercent, rightPercent) {
|
function setSummaryCard(kind, zone, text, value) {
|
||||||
|
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');
|
||||||
|
|
||||||
|
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 updateSummaryBar(connected, leftPercent, rightPercent, imbalance) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
setSummaryCard('plc', 'ok', 'OK', 'Online');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected) {
|
||||||
const banner = document.getElementById('alarm-banner');
|
const banner = document.getElementById('alarm-banner');
|
||||||
const text = document.getElementById('alarm-text');
|
const text = document.getElementById('alarm-text');
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
banner.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const leftCritical = leftPercent >= CRITICAL_PERCENT;
|
const leftCritical = leftPercent >= CRITICAL_PERCENT;
|
||||||
const rightCritical = rightPercent >= CRITICAL_PERCENT;
|
const rightCritical = rightPercent >= CRITICAL_PERCENT;
|
||||||
|
const imbalanceCritical = imbalancePercent >= IMBALANCE_CRITICAL_PERCENT;
|
||||||
|
|
||||||
if (leftCritical || rightCritical) {
|
if (!leftCritical && !rightCritical && !imbalanceCritical) {
|
||||||
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');
|
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() {
|
function redrawGauges() {
|
||||||
|
|
@ -1322,6 +1550,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
latestData = d;
|
latestData = d;
|
||||||
|
|
||||||
|
const connected = !!d.connected;
|
||||||
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;
|
||||||
const leftKN = Number(d.sila_l_kn) || 0;
|
const leftKN = Number(d.sila_l_kn) || 0;
|
||||||
|
|
@ -1331,7 +1560,7 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
const imbalance = Number(d.imbalance_percent) || 0;
|
const imbalance = Number(d.imbalance_percent) || 0;
|
||||||
const bias = Number(d.bias_percent) || 0;
|
const bias = Number(d.bias_percent) || 0;
|
||||||
|
|
||||||
setStatusConnected(!!d.connected);
|
setStatusConnected(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) + ' ' + UNIT_FORCE;
|
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE;
|
||||||
|
|
@ -1348,11 +1577,13 @@ const uiHTML = `<!DOCTYPE html>
|
||||||
|
|
||||||
applyChannelState('l', leftPercent);
|
applyChannelState('l', leftPercent);
|
||||||
applyChannelState('r', rightPercent);
|
applyChannelState('r', rightPercent);
|
||||||
updateAlarmBanner(leftPercent, rightPercent);
|
updateSummaryBar(connected, leftPercent, rightPercent, imbalance);
|
||||||
|
updateAlarmBanner(leftPercent, rightPercent, imbalance, connected);
|
||||||
redrawGauges();
|
redrawGauges();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Live fetch error:', err);
|
console.warn('Live fetch error:', err);
|
||||||
setStatusConnected(false);
|
setStatusConnected(false);
|
||||||
|
updateSummaryBar(false, 0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue