diff --git a/main.go b/main.go index b8dee58..279a707 100644 --- a/main.go +++ b/main.go @@ -47,9 +47,11 @@ type PLCConfig struct { } type ThresholdsConfig struct { - WarningPercent float64 `yaml:"warning_percent"` - CriticalPercent float64 `yaml:"critical_percent"` - GaugeMaxPercent float64 `yaml:"gauge_max_percent"` + WarningPercent float64 `yaml:"warning_percent"` + CriticalPercent float64 `yaml:"critical_percent"` + GaugeMaxPercent float64 `yaml:"gauge_max_percent"` + ImbalanceWarningPercent float64 `yaml:"imbalance_warning_percent"` + ImbalanceCriticalPercent float64 `yaml:"imbalance_critical_percent"` LegacyWarningKn float64 `yaml:"warning_kn,omitempty"` LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"` @@ -88,7 +90,9 @@ type DBConfig struct { func defaultConfig() Config { return Config{ - Server: ServerConfig{ListenAddr: ":8080"}, + Server: ServerConfig{ + ListenAddr: ":8080", + }, PLC: PLCConfig{ IP: "192.168.0.1", DBNum: 1001, @@ -100,14 +104,20 @@ func defaultConfig() Config { ReconnectDelaySec: 5, }, Thresholds: ThresholdsConfig{ - WarningPercent: 80, - CriticalPercent: 95, - GaugeMaxPercent: 130, + WarningPercent: 80, + CriticalPercent: 95, + 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{ - 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", LeftLabel: "LEVI STEBER", RightLabel: "DESNI STEBER", @@ -163,6 +173,7 @@ func normalizeConfig(cfg *Config) { 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 } @@ -172,6 +183,15 @@ func normalizeConfig(cfg *Config) { if cfg.Thresholds.GaugeMaxPercent <= 0 { 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 { cfg.Trend.Minutes = def.Trend.Minutes @@ -316,18 +336,20 @@ type HistoryResponse struct { } 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 + Title string + Subtitle string + LeftLabel string + RightLabel string + UnitForce string + UnitPct string + MaxTonnage float64 + WarningPercent float64 + CriticalPercent float64 + GaugeMaxPercent float64 + ImbalanceWarningPercent float64 + ImbalanceCriticalPercent float64 + PollMs int + DefaultWindow string } 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) { dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs) database, err := sql.Open("sqlite3", dsn) @@ -426,8 +483,8 @@ CREATE TABLE IF NOT EXISTS samples ( 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 + imbalance_pct REAL NOT NULL DEFAULT 0, + bias_pct REAL NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); ` @@ -436,6 +493,15 @@ CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); 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 } @@ -664,7 +730,6 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) { if len(points) <= cfg.DB.MaxChartPoints { return points, 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) { 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), + 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, + ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent, + 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") @@ -835,7 +902,7 @@ const uiHTML = ` position: relative; width: 100%; max-width: 500px; - height: 390px; + height: 360px; margin: 0 auto; } @@ -856,6 +923,69 @@ const uiHTML = ` width: min(92vw, 1800px); 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; }
@@ -863,7 +993,7 @@ const uiHTML = ` @@ -886,6 +1016,41 @@ const uiHTML = ` +